mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-14 11:17:16 -04:00
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:
1 parent
f5e204eb1d
commit
db2e1d7da2
27 files changed
+978
-90
No files matched your search
@@ -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'
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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')
|
||||
@@ -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))
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
Reference in new issue
Block a user