feat(desktop): add desktop uninstall and legacy LXQt cleanup (#493)

This commit is contained in:
Kurt Himebauch authored and GitHub committed 2026-10-10 10:59:32 -04:00
1 parent 79d92111e4
commit 67a9ea2f42
28 files changed
+728 -25

No files matched your search

+7
View File
@@ -65,6 +65,13 @@ def commitTime = (gitValue(['show', '-s', '--format=%ct', 'HEAD']) ?: '0').with
def desktopKde = rootProject.file('tools/desktop-kde/release.env').readLines()
.findAll { it.contains('=') && !it.startsWith('#') }
.collectEntries { def kv = it.split('=', 2); [(kv[0].trim()): kv[1].trim()] }
def desktopRemovalSources = new groovy.json.JsonSlurper().parse(file('src/main/assets/desktop-removal/sources.json'))
if (desktopRemovalSources.archives[desktopKde.DESKTOP_KDE_TAG] != desktopKde.DESKTOP_KDE_SHA256) {
throw new GradleException('Desktop removal inventories do not match the pinned archive')
}
if (!file('src/main/assets/desktop-removal/versions.txt').readLines().contains(desktopKde.DESKTOP_KDE_TAG)) {
throw new GradleException('Regenerate desktop removal inventories for ' + desktopKde.DESKTOP_KDE_TAG)
}
// tools/release/variants.txt as "package=suffix;...", so the updater picks the apk of the package it runs as.
def variantSuffixes = rootProject.file('tools/release/variants.txt').readLines()
.findAll { it.trim() && !it.startsWith('#') }
Binary file not shown.
@@ -0,0 +1,49 @@
{
"archives": {
"lxqt-r1": "c2bb860b3b1688287451e2495be5db313ec07f8c8af501dfac2768a9f417bc86",
"desktop-kde-r1": "55747e75d0ec055ab7ea1e92a757f3be227cc3205bb9101f5b53cadef6318579",
"desktop-kde-r2": "9ba832677b498654136c69d8c8330434d17e4d53cd099acac9bde3ba429b35bf",
"emulators-r1": "a1f4806a9663d28f424b4cab1277e0ede80a92e69de5bc063cdc8b45af220164"
},
"desktopApplications": {
"ark-26.08.1-1-aarch64.pkg.tar.xz": "93e2c2b75ff20cd29b4f312ed1979ef6f27255b93232b93bfcdb31239903c1d4",
"breeze-6.7.5-1-aarch64.pkg.tar.xz": "1d7f4eb9d8d234caaea23621909f5233d19462c2f058afeeb54bed7b81bd03bd",
"breeze-gtk-6.7.5-1-any.pkg.tar.xz": "a0f59bc4fc426879c8f8a97f6dcc707fb7d1b52913da5eb7e9db83777b6957b7",
"breeze-icons-6.30.0-1-aarch64.pkg.tar.xz": "4bae4689032501156687a227024e56554820747644953c67fc8d2e2b6b0b045a",
"dolphin-26.08.1-1-aarch64.pkg.tar.xz": "fef738ab8132d3eba1156b98c116699ce0a5efcb4cb462efbacdcf4c47f79a23",
"firefox-157.0.1-1-aarch64.pkg.tar.xz": "d142fdb8a339a65eca7cea6a2bba5b29202386166abd9d59851d6757c9eb2163",
"gwenview-26.08.1-1-aarch64.pkg.tar.xz": "a9dc7d5ab73fa677905d5a57248b1b9b404b6b16e862958cc7443d387f684a47",
"kate-26.08.1-1-aarch64.pkg.tar.xz": "acf50710042746f243a3b219a1ae0f788e338f3dd9d75093e8bb671a618b83b6",
"kde-cli-tools-6.7.5-1-aarch64.pkg.tar.xz": "af466398a912a994a97fadfe46aff401d88a90d7fdce7e843dc4218098fa38ac",
"kde-gtk-config-6.7.5-1-aarch64.pkg.tar.xz": "ba6cb4cb01dbb3f2c247f2f6973ede3b64c0849f89340217b51dfbf11cb8cb5c",
"kinfocenter-6.7.5-1-aarch64.pkg.tar.xz": "f3d5416db96b50f9a5b8a284d82ae21d834a8cce646010abdd7087491c399b0b",
"konsole-26.08.1-1-aarch64.pkg.tar.xz": "1ecf87c0d11d620ebe4aeafb02788e0d5f4fe9019ad9c5d9f1011c43237dd5ec",
"kscreen-6.7.5-1-aarch64.pkg.tar.xz": "8fbaa6fedf93edeba021a5416b551663654919b27c2ee237d696ec0b0ce7d5b7",
"kwin-6.7.5-1-aarch64.pkg.tar.xz": "a7535710c74b90359f80306134b2b7581132cbac508dc84b7e07ef275cc8c3fe",
"mpv-1:0.41.0-6-aarch64.pkg.tar.xz": "6c89d00736820210a3c7f32af4cf95d155191d463f9df8bd22137c45f30a554a",
"plasma-desktop-6.7.5-1-aarch64.pkg.tar.xz": "b266dd42cea965b5ab1cc1ae600877e9d3bc83ec642c61fe1669c4457e641a55",
"plasma-integration-6.7.5-1-aarch64.pkg.tar.xz": "daa96da14d9a4d12c0d9c0341e1be6836a9a437c469d61da5e9844d51e2606cf",
"plasma-pa-6.7.5-1-aarch64.pkg.tar.xz": "02dbefe9d3e0069b2bb37cac2a6be18b8f0a1887c87704a0716e7a9268eadcd8",
"plasma-workspace-6.7.5-1-aarch64.pkg.tar.xz": "3a1455857afaa9ccf342ab6167f0f15794ee9d248b0925398ea6f62f12730a86",
"spectacle-1:6.7.5-1-aarch64.pkg.tar.xz": "0c9837412ea6119edef18bcb0a8fceb74634cb35be0ca78b3e1dd1ff2443866e",
"systemsettings-6.7.5-1-aarch64.pkg.tar.xz": "b02b6a726857d5125c92d1ec95379216c7a3bc1f187a3b46e88c0d056b29ce70",
"xdg-desktop-portal-kde-6.7.5-1-aarch64.pkg.tar.xz": "2c57d17d34d577023eb9cd7b0fca1ba9163549afe60450f6271aa148af099ef2"
},
"legacyApplications": {
"featherpad-1.6.3-1-aarch64.pkg.tar.xz": "fcac55215815c0b712453f9bcc09abfe4b518e36025a111a7a7598ef04913f3d",
"labwc-0.20.2-1-aarch64.pkg.tar.xz": "bcd4cc3945165b0fdd33c0117cd7b9dfd05cf8c327326c37f3c3a27ef23ee5ca",
"lximage-qt-2.4.0-1-aarch64.pkg.tar.xz": "5d6bdd5472ea6bcb4b993b36df3ab7010bb2e62b36a78a649708fc9a52cb0928",
"lxqt-config-2.4.0-1-aarch64.pkg.tar.xz": "4b601d798fc7689ee76787dbef8c8958c02429ee60248cd99d950c919dbefba5",
"lxqt-panel-2.4.1-1-aarch64.pkg.tar.xz": "a141e0f8049e73f859d843510c6f45d6ed9087c990bcebf44f5f2430621024c4",
"lxqt-qtplugin-2.4.0-3-aarch64.pkg.tar.xz": "61c8f828be73111ee0bd5e3163b0d64c2098dd0189710a190f42be11137695cf",
"lxqt-runner-2.4.0-1-aarch64.pkg.tar.xz": "5afa74652a37366ea4e7774cf33379977582fbdca599cef1005ebf637cbcae4b",
"lxqt-session-2.4.0-2-aarch64.pkg.tar.xz": "e9c7c8a0011859029622ad202e0d3826e4e5bc8d64659031f6efa211456ff6eb",
"lxqt-themes-2.4.0-1-any.pkg.tar.xz": "7ea16c62a1fa70cdac13cb3f8fdcdaad299ae1f5bd514acfda671484755787bf",
"lxqt-wayland-session-0.4.1-1-any.pkg.tar.xz": "cd8dc11f5035469d23b6bbd937a90593402428c43de37517412aa43fe1fc6b10",
"papirus-icon-theme-20260801-1-any.pkg.tar.xz": "4aae3f47b800a60d6fd360e55adc581408ceaafc481c5a420a681741a3966510",
"pcmanfm-qt-2.4.1-1-aarch64.pkg.tar.xz": "7ec1a6b39dd0e4055b39f0e2c0549ea263d83004e1b821fb9e42f1d7f5fff0f9",
"qterminal-2.4.0-1-aarch64.pkg.tar.xz": "aa4adc9c9d47641b9452ec8d879038a9cb4762c3b6b5299045fac968fd0ed22d",
"swaybg-1.2.2-1-aarch64.pkg.tar.xz": "a26caa16ca7d581b5ca05758677058b4b60af10dc8d83e447ad6824b762b48b8",
"wlr-randr-0.5.0-1-aarch64.pkg.tar.xz": "58c3651c6a15dc8f95a310c7e225f759aa766be2fb3c0981a9a8b567854675b3"
}
}
@@ -0,0 +1,2 @@
desktop-kde-r1
desktop-kde-r2
@@ -139,6 +139,7 @@ class MainActivity : ComponentActivity() {
private var pkgStage by mutableStateOf<String?>(null)
private var pkgPercent by mutableIntStateOf(-1)
private var desktopInstalled by mutableStateOf(false)
private var desktopPresent by mutableStateOf(false)
private var offlineAccount by mutableStateOf<String?>(null)
private var offline by mutableStateOf(false)
private var showPerformance by mutableStateOf(false)
@@ -560,7 +561,7 @@ class MainActivity : ComponentActivity() {
shortcutLibraryScanning = shortcutLibraryScanning,
gameSyncFolder = gameSyncFolder,
busy = busy, stage = stage, percent = percent,
desktopInstalled = desktopInstalled,
desktopInstalled = desktopInstalled, desktopPresent = desktopPresent,
offlineAccount = offlineAccount, offline = offline,
frameGenLabel = frameGenLabel, romsDir = romsDir, logsEnabled = logsEnabled, agentCommands = agentCommands,
steamGames = steamGames, emulators = emulatorList, running = runningLabel,
@@ -669,6 +670,7 @@ class MainActivity : ComponentActivity() {
// The activity re-attaches to the session that is running; nothing restarts.
onResume = { startActivity(Intent(this, SessionActivity::class.java)) },
onSteamSettings = { openModeSettings(SessionService.MODE_STEAM) },
onRemoveDesktop = { removeDesktop() },
onDesktopSettings = { openModeSettings(SessionService.MODE_DESKTOP) },
onInstallPackage = { id -> installPackage(id) },
onRemovePackage = { id -> removePackage(id) },
@@ -1198,6 +1200,7 @@ class MainActivity : ComponentActivity() {
private fun refreshPackages() {
packageRows = catalog?.map { PackageRow(it.id, it.kind, it.notes) }
desktopInstalled = DesktopCatalog.desktopInstalled(this)
desktopPresent = DesktopCatalog.desktopPresent(this)
}
private fun launchProgram(path: String) {
@@ -1556,6 +1559,7 @@ class MainActivity : ComponentActivity() {
if (!busy && LinuxRuntimeInstaller.isBusy()) followRuntimeOperation { LinuxRuntimeInstaller.attach(it) }
addedGamesArt = SessionPrefs.addedGamesArt(this)
desktopInstalled = DesktopCatalog.desktopInstalled(this)
desktopPresent = DesktopCatalog.desktopPresent(this)
offlineAccount = OfflineMode.account(this)
offline = OfflineMode.enabled(this)
installed = LinuxRuntimeInstaller.installedVersion(this)
@@ -1668,6 +1672,15 @@ class MainActivity : ComponentActivity() {
protons.protonBusyId != null || ProtonExtras.installInProgress || pkgStage != null ||
components.compBusy != null || saveBusy != null || com.droiddeck.launcher.store.UserAppsState.working != null
private fun removeDesktop() {
if (runtimeChangesBlocked()) return
val operation = LinuxRuntimeInstaller.beginMaintenance(this, getString(R.string.desktop_removing)) ?: return
com.droiddeck.launcher.runtime.RuntimeInstallService.keepRemovalAlive(this)
followRuntimeOperation { listener ->
operation.run(listener) { progress -> DesktopCatalog.removeDesktop(this, progress) }
}
}
private fun removeRuntime() {
if (runtimeChangesBlocked()) return
val removal = LinuxRuntimeInstaller.beginUninstall(this) ?: return
@@ -1690,7 +1703,7 @@ class MainActivity : ComponentActivity() {
private fun followRuntimeOperation(run: (LinuxRuntimeInstaller.ProgressListener) -> Boolean?) {
busy = true
failed = false
stage = getString(if (LinuxRuntimeInstaller.isRemoving()) R.string.main_removing_runtime else R.string.store_starting)
stage = getString(if (LinuxRuntimeInstaller.isMaintaining()) R.string.desktop_removing else if (LinuxRuntimeInstaller.isRemoving()) R.string.main_removing_runtime else R.string.store_starting)
percent = -1
Thread({
val ok = run(LinuxRuntimeInstaller.ProgressListener { s, p ->
@@ -315,7 +315,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.isRemoving()) {
if (com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.isRemoving() ||
com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.isMaintaining()) {
runtimeRemovalBlocked = true
android.widget.Toast.makeText(this, R.string.session_wait_removal, android.widget.Toast.LENGTH_SHORT).show()
finish()
@@ -101,9 +101,21 @@ object DesktopCatalog {
fun desktopInstalled(context: Context): Boolean = desktopInstalled(LinuxRuntime.rootDir(context))
fun desktopInstalled(root: File): Boolean =
!File(root, DesktopRemoval.PENDING).exists() &&
File(root, "usr/bin/kwin_wayland").isFile &&
FileUtils.readString(File(root, ".droiddeck-pkg-$DESKTOP_ID"))?.trim() == BuildConfig.DESKTOP_KDE_TAG
fun desktopPresent(context: Context): Boolean {
val root = LinuxRuntime.rootDir(context)
return File(root, DesktopRemoval.PENDING).exists() || marker(context, DESKTOP_ID).exists()
}
fun cleanLegacy(context: Context, listener: LinuxRuntimeInstaller.ProgressListener? = null) =
DesktopRemoval.cleanLegacy(context, listener)
fun removeDesktop(context: Context, listener: LinuxRuntimeInstaller.ProgressListener): String? =
DesktopRemoval.remove(context, listener)
/** Downloads, verifies and installs one package. Returns null on success, else a message. */
fun install(context: Context, entry: Entry, listener: LinuxRuntimeInstaller.ProgressListener?): String? {
val root = LinuxRuntime.rootDir(context)
@@ -143,7 +155,13 @@ object DesktopCatalog {
if (!LinuxRuntimeInstaller.extract(context, download, root, listener)) return context.getString(R.string.deskpkg_extract_failed)
}
}
if (entry.id == DESKTOP_ID) {
cleanLegacy(context, listener)
val pending = File(root, DesktopRemoval.PENDING)
check(!pending.exists() || pending.delete()) { "Could not finish desktop installation" }
}
FileUtils.writeString(marker(context, entry.id), entry.version)
check(installed(context, entry.id) == entry.version) { "Could not finish package installation" }
return null
} catch (e: Exception) {
Log.e(TAG, "install ${entry.id}", e)
@@ -0,0 +1,97 @@
package com.droiddeck.launcher.runtime
import java.io.File
import java.io.IOException
import java.nio.file.Files
import java.nio.file.LinkOption.NOFOLLOW_LINKS
import java.nio.file.attribute.BasicFileAttributes
import java.security.MessageDigest
/** Removes only unchanged, recorded package files. Never visits home or follows a symlink. */
internal object DesktopFileRemoval {
data class Entry(val kind: String, val size: Long, val identity: String, val path: String) {
companion object {
fun parse(line: String): Entry {
val parts = line.split('\t', limit = 4)
require(parts.size == 4 && parts[0] in setOf("F", "L")) { "Invalid removal inventory" }
return Entry(parts[0], parts[1].toLong(), parts[2], parts[3])
}
}
}
data class Result(val removed: Int, val bytes: Long, val kept: Int)
fun remove(root: File, entries: Iterable<Entry>, protected: Set<String>, progress: (Int) -> Unit = {}): Result {
var removed = 0
var bytes = 0L
var kept = 0
val buffer = ByteArray(65536)
val total = (entries as? Collection<*>)?.size ?: 0
val directories = mutableSetOf<File>()
for ((index, entry) in entries.withIndex()) {
val file = safeFile(root, entry.path)
if (file == null || entry.path in protected) { kept++; continue }
val attrs = try { Files.readAttributes(file.toPath(), BasicFileAttributes::class.java, NOFOLLOW_LINKS) }
catch (_: java.nio.file.NoSuchFileException) { continue }
val matches = when (entry.kind) {
"L" -> attrs.isSymbolicLink && Files.readSymbolicLink(file.toPath()).toString() == entry.identity
else -> attrs.isRegularFile && attrs.size() == entry.size && sha(file, buffer) == entry.identity
}
if (!matches) { kept++; continue }
Files.delete(file.toPath()) // A failure propagates; the pending marker makes retry possible.
removed++
bytes += if (attrs.isRegularFile) attrs.size() else 0
var parent = file.parentFile
while (parent != root && parent != null) { directories.add(parent); parent = parent.parentFile }
if (index % 200 == 0) progress(if (total > 0) index * 99 / total else -1)
}
// Only now-empty parents of removed files, never a recursive delete of a package directory.
for (dir in directories.sortedByDescending { it.path.length }) {
if (dir.list()?.isEmpty() == true && !dir.delete()) throw IOException("Cannot remove $dir")
}
progress(99)
return Result(removed, bytes, kept)
}
internal fun safeFile(root: File, name: String): File? {
if (Files.isSymbolicLink(root.toPath())) return null
val parts = name.split('/')
if (parts.firstOrNull() !in setOf("usr", "etc") || parts.any { it.isEmpty() || it == "." || it == ".." }) return null
// Even an in-root symlink in a parent can redirect deletion into a user's data.
var parent = root
for (part in parts.dropLast(1)) {
parent = File(parent, part)
if (Files.isSymbolicLink(parent.toPath())) return null
}
return File(root, name)
}
internal fun sha(file: File, buffer: ByteArray = ByteArray(65536)): String {
val digest = MessageDigest.getInstance("SHA-256")
file.inputStream().buffered().use { input ->
while (true) { val n = input.read(buffer); if (n < 0) break; digest.update(buffer, 0, n) }
}
val digits = "0123456789abcdef"
return buildString(64) {
for (byte in digest.digest()) { val b = byte.toInt() and 255; append(digits[b ushr 4]); append(digits[b and 15]) }
}
}
/** The base runtime's pacman database is still present: desktop archives never replace it. */
fun runtimeFiles(root: File): Set<String> = buildSet {
val database = File(root, "var/lib/pacman/local")
if (!database.isDirectory || Files.isSymbolicLink(database.toPath())) throw IOException("Runtime package database missing")
for (pkg in database.listFiles().orEmpty()) {
val files = File(pkg, "files")
if (!pkg.isDirectory || Files.isSymbolicLink(pkg.toPath()) || !files.isFile || Files.isSymbolicLink(files.toPath())) continue
var inFiles = false
files.forEachLine { line ->
when {
line == "%FILES%" -> inFiles = true
line.startsWith('%') -> inFiles = false
inFiles && line.isNotEmpty() && !line.endsWith('/') -> add(line)
}
}
}
if (isEmpty()) throw IOException("Runtime package inventory missing")
}
}
@@ -0,0 +1,77 @@
package com.droiddeck.launcher.runtime
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.FileUtils
import java.io.File
import java.util.zip.GZIPInputStream
/** Inventories are generated from pinned archives by tools/desktop-kde/removal-manifests.py. */
internal object DesktopRemoval {
const val PENDING = ".droiddeck-desktop-removing"
private const val CLEANING = ".droiddeck-lxqt-cleaning-v1"
private const val CLEANED = ".droiddeck-lxqt-cleaned-v1"
private fun lines(context: Context, name: String) =
GZIPInputStream(context.assets.open("desktop-removal/$name")).bufferedReader().use { it.readLines() }
private fun protected(context: Context, root: File) =
DesktopFileRemoval.runtimeFiles(root) + lines(context, "emulators-keep.txt.gzip")
/** Called before a guest starts or just after KDE extraction; retries after a process death. */
fun cleanLegacy(context: Context, listener: LinuxRuntimeInstaller.ProgressListener? = null) {
val root = LinuxRuntime.rootDir(context)
if (FileUtils.readString(File(root, CLEANED))?.trim() == "1") return
if (!File(root, CLEANING).exists() && !File(root, ".droiddeck-pkg-desktop").exists() && !File(root, "usr/bin/lxqt-session").exists()) return
listener?.onProgress(context.getString(R.string.desktop_cleaning_legacy), -1)
val keep = protected(context, root) + lines(context, "kde-keep.txt.gzip")
val pending = File(root, CLEANING)
FileUtils.writeString(pending, "1")
check(FileUtils.readString(pending)?.trim() == "1") { "Could not reserve LXQt cleanup" }
val result = GZIPInputStream(context.assets.open("desktop-removal/lxqt-r1.tsv.gzip")).bufferedReader().use { reader ->
DesktopFileRemoval.remove(root, reader.lineSequence().map(DesktopFileRemoval.Entry::parse).asIterable(), keep)
}
FileUtils.writeString(File(root, CLEANED), "1")
check(FileUtils.readString(File(root, CLEANED))?.trim() == "1") { "Could not finish LXQt cleanup" }
File(root, ".droiddeck-pkg-desktop").delete()
pending.delete()
Log.i("DesktopRemoval", "LXQt: $result")
}
fun remove(context: Context, listener: LinuxRuntimeInstaller.ProgressListener): String? = try {
val root = LinuxRuntime.rootDir(context)
val pending = File(root, PENDING)
val marker = File(root, ".droiddeck-pkg-${DesktopCatalog.DESKTOP_ID}")
val version = FileUtils.readString(pending)?.trim() ?: FileUtils.readString(marker)?.trim()
val versions = context.assets.open("desktop-removal/versions.txt").bufferedReader().use { it.readLines() }
check(version in versions) { context.getString(R.string.desktop_removal_unknown) }
// Validate all inventories and the runtime database before dropping the installed marker.
val entries = lines(context, "kde-remove.tsv.gzip").map(DesktopFileRemoval.Entry::parse)
val keep = protected(context, root)
cleanLegacy(context, listener)
FileUtils.writeString(pending, version!!)
check(FileUtils.readString(pending)?.trim() == version) { "Could not reserve desktop removal" }
if (marker.exists() && !marker.delete()) error("Could not clear desktop marker")
val removing = context.getString(R.string.desktop_removing)
val result = DesktopFileRemoval.remove(root, entries, keep) { listener.onProgress(removing, it) }
// APK-staged desktop files are matched against the APK too; keep user-modified copies.
for ((asset, path) in com.droiddeck.launcher.session.SessionFiles.DESKTOP_FILES) {
val target = DesktopFileRemoval.safeFile(root, path) ?: continue
if (!isRegularFile(target)) continue
val content = context.assets.open("linuxfs/$asset").use { it.readBytes() }
if (target.length() == content.size.toLong() && target.inputStream().use { it.readBytes() }.contentEquals(content)) {
if (!target.delete()) error("Could not remove $path")
}
}
if (!pending.delete()) error("Could not finish desktop removal")
Log.i("DesktopRemoval", "KDE: $result")
listener.onProgress(context.getString(R.string.desktop_removed), 100)
null
} catch (e: Exception) {
Log.e("DesktopRemoval", "remove", e)
e.message ?: context.getString(R.string.deskpkg_install_failed)
}
private fun isRegularFile(file: File) = java.nio.file.Files.isRegularFile(file.toPath(), java.nio.file.LinkOption.NOFOLLOW_LINKS)
}
@@ -85,6 +85,7 @@ public final class LinuxRuntimeInstaller {
volatile int percent = -1;
volatile boolean ok;
final boolean removal;
boolean maintenance;
Job(boolean removal, String stage) { this.removal = removal; this.stage = stage; }
}
@@ -139,7 +140,7 @@ public final class LinuxRuntimeInstaller {
Job job;
boolean owner;
synchronized (JOB_LOCK) {
if (running != null && running.removal) return false;
if (running != null && (running.removal || running.maintenance)) return false;
owner = running == null;
if (owner) { running = new Job(false, context.getString(R.string.user_apps_starting)); removalError = null; }
job = running;
@@ -171,7 +172,7 @@ 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 && !running.removal;
return running != null && !running.removal && !running.maintenance;
}
}
@@ -183,6 +184,53 @@ public final class LinuxRuntimeInstaller {
synchronized (JOB_LOCK) { return running != null && running.removal; }
}
public static boolean isMaintaining() {
synchronized (JOB_LOCK) { return running != null && running.maintenance; }
}
/** Reserve an in-place package operation with the same exclusion/progress as runtime installs. */
public static Maintenance beginMaintenance(Context context, String stage) {
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;
synchronized (JOB_LOCK) {
if (running != null) return null;
removalError = null;
running = new Job(false, stage);
running.maintenance = true;
return new Maintenance(running);
}
}
public interface MaintenanceWork { String run(ProgressListener listener); }
public static final class Maintenance {
private final Job job;
private final java.util.concurrent.atomic.AtomicBoolean started = new java.util.concurrent.atomic.AtomicBoolean();
private Maintenance(Job job) { this.job = job; }
public boolean run(ProgressListener listener, MaintenanceWork work) {
if (!started.compareAndSet(false, true)) return join(job, listener);
if (listener != null) job.listeners.add(listener);
try {
removalError = work.run((stage, percent) -> {
job.stage = stage;
job.percent = percent;
for (ProgressListener l : job.listeners) l.onProgress(stage, percent);
});
job.ok = removalError == null;
return job.ok;
} catch (Exception e) {
removalError = e.getMessage();
Log.e(TAG, "maintenance", e);
return false;
} finally {
synchronized (JOB_LOCK) { running = null; }
job.done.countDown();
if (listener != null) job.listeners.remove(listener);
}
}
}
public static String removalError() { return removalError; }
public static boolean hasRemovalPending(Context context) {
@@ -30,7 +30,7 @@ class RuntimeInstallService : Service() {
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(getString(if (removal) R.string.rtinst_removing else R.string.user_apps_starting), -1))
startForeground(NOTIFICATION_ID, notification(getString(if (LinuxRuntimeInstaller.isMaintaining()) R.string.desktop_removing else if (removal) R.string.rtinst_removing else R.string.user_apps_starting), -1))
if (release == null && !removal) {
stopSelf(startId)
return START_NOT_STICKY
@@ -68,7 +68,7 @@ class RuntimeInstallService : Service() {
PendingIntent.FLAG_IMMUTABLE)
return Notification.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_stat_session)
.setContentTitle(getString(if (LinuxRuntimeInstaller.isRemoving()) R.string.rtinst_title_removing else R.string.rtinst_title_installing))
.setContentTitle(getString(if (LinuxRuntimeInstaller.isMaintaining()) R.string.desktop_removing else if (LinuxRuntimeInstaller.isRemoving()) R.string.rtinst_title_removing else R.string.rtinst_title_installing))
.setContentText(stage)
.setProgress(100, percent.coerceIn(0, 100), percent < 0)
.setContentIntent(open)
@@ -20,6 +20,19 @@ object SessionFiles {
/** The driver sets staged under it: classic Wine 11 (Valve, GE) and the system-thread interface (Proton-CachyOS). */
val DIRECTAUDIO_SETS = arrayOf("linux-wine11", "linux-wine11-systhread")
// Staged only with a complete desktop; removal matches these against the APK as well.
internal val DESKTOP_FILES = arrayOf(
"usr/local/bin/droiddeck-desktop" to "usr/local/bin/droiddeck-desktop",
// Games and emulators from the menu, full screen in a gamescope of their own.
"usr/local/bin/droiddeck-gpu" to "usr/local/bin/droiddeck-gpu",
"usr/local/bin/droiddeck-desktop-gpu" to "usr/local/bin/droiddeck-desktop-gpu",
"usr/local/bin/droiddeck-desktop-window-rules" to "usr/local/bin/droiddeck-desktop-window-rules",
// KWin at the app's surface size, for Plasma's session (it starts KWin through this name).
"usr/local/bin/kwin_wayland_wrapper" to "usr/local/bin/kwin_wayland_wrapper",
"usr/lib/firefox/defaults/pref/droiddeck.js" to "usr/lib/firefox/defaults/pref/droiddeck.js",
"etc/xdg/autostart/droiddeck-clipboard.desktop" to "etc/xdg/autostart/droiddeck-clipboard.desktop",
)
/**
* The libraries and scripts the session runs, refreshed from the apk at every launch.
*
@@ -30,6 +43,8 @@ object SessionFiles {
* rename, so a session that still has one mapped keeps the file it opened.
*/
fun stage(context: Context, root: File) {
if (DesktopCatalog.desktopInstalled(root)) try { DesktopCatalog.cleanLegacy(context) }
catch (e: Exception) { Log.w(TAG, "LXQt cleanup will retry next session", e) }
GameEnvironmentStore.publish(context)
WinComponents.publish(context)
val files = arrayOf(
@@ -95,19 +110,6 @@ object SessionFiles {
"usr/bin/steamos-polkit-helpers/jupiter-biosupdate" to "usr/bin/steamos-polkit-helpers/jupiter-biosupdate",
"usr/bin/steamos-polkit-helpers/jupiter-dock-updater" to "usr/bin/steamos-polkit-helpers/jupiter-dock-updater",
)
// The desktop's launchers, only where the desktop package is installed:
// staging them into a runtime without it would make the desktop look present when it is not.
val desktop = arrayOf(
"usr/local/bin/droiddeck-desktop" to "usr/local/bin/droiddeck-desktop",
// Games and emulators from the menu, full screen in a gamescope of their own.
"usr/local/bin/droiddeck-gpu" to "usr/local/bin/droiddeck-gpu",
"usr/local/bin/droiddeck-desktop-gpu" to "usr/local/bin/droiddeck-desktop-gpu",
"usr/local/bin/droiddeck-desktop-window-rules" to "usr/local/bin/droiddeck-desktop-window-rules",
// KWin at the app's surface size, for Plasma's session (it starts KWin through this name).
"usr/local/bin/kwin_wayland_wrapper" to "usr/local/bin/kwin_wayland_wrapper",
"usr/lib/firefox/defaults/pref/droiddeck.js" to "usr/lib/firefox/defaults/pref/droiddeck.js",
"etc/xdg/autostart/droiddeck-clipboard.desktop" to "etc/xdg/autostart/droiddeck-clipboard.desktop",
)
// The patched gamescope (tools/gamescope): the runtime's own version rebuilt with the ARM64
// client fixes, over /usr/local/bin so it comes first in the session's PATH. Only when the
// apk carries it - a build without the asset leaves the runtime's copy alone.
@@ -137,7 +139,7 @@ object SessionFiles {
val dir = asset.substringBeforeLast('/')
runCatching { context.assets.list("linuxfs/$dir")?.contains(asset.substringAfterLast('/')) == true }.getOrDefault(false)
}
val all = (if (DesktopCatalog.desktopInstalled(root)) files + desktop else files) + optional
val all = (if (DesktopCatalog.desktopInstalled(root)) files + DESKTOP_FILES else files) + optional
for ((asset, relative) in all) {
val target = File(root, relative)
val staged = File(target.parentFile, target.name + ".staged")
@@ -205,6 +205,10 @@ class SessionService : Service() {
return START_NOT_STICKY
}
}
if (com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.isMaintaining()) {
stopSelf(startId)
return START_NOT_STICKY
}
startForeground(NOTIFICATION_ID, buildNotification())
if (SessionState.running) return START_NOT_STICKY
if (SessionState.stopRequested) {
@@ -424,11 +424,18 @@ private fun Content(
private fun DesktopCard(s: FrontEndState, a: FrontEndActions) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
var confirmRemove by rememberSaveable { mutableStateOf(false) }
if (confirmRemove) ConfirmDialog(
stringResource(R.string.desktop_remove_title), stringResource(R.string.desktop_remove_text),
stringResource(R.string.common_uninstall), a.onRemoveDesktop, { confirmRemove = false },
)
val actions: @Composable () -> Unit = {
Actions {
// Enabled without a runtime or the desktop: the session's loading screen installs them first.
PrimaryButton(if (s.desktopInstalled) stringResource(R.string.content_open_desktop) else stringResource(R.string.content_install_open_desktop), enabled = !s.busy, main = true, onClick = a.onDesktop)
Cog(onClick = a.onDesktopSettings)
if (s.desktopPresent) SecondaryButton(stringResource(R.string.common_uninstall),
enabled = !s.runtimeActionsBlocked) { confirmRemove = true }
BusyChip(s)
}
}
@@ -86,6 +86,7 @@ class FrontEndState(
val stage: String,
val percent: Int,
val desktopInstalled: Boolean,
val desktopPresent: Boolean = desktopInstalled,
val offlineAccount: String?,
val offline: Boolean,
val frameGenLabel: String,
@@ -175,6 +176,7 @@ class FrontEndActions(
val onResume: () -> Unit,
val onSteamSettings: () -> Unit,
val onDesktopSettings: () -> Unit,
val onRemoveDesktop: () -> Unit = {},
val onInstallPackage: (String) -> Unit,
val onRemovePackage: (String) -> Unit,
val onRuntime: () -> Unit,
+6
View File
@@ -896,6 +896,12 @@
<string name="content_page_gone">That page is gone. Pick a section on the left.</string>
<string name="content_open_desktop">Open desktop</string>
<string name="content_install_open_desktop">Install &amp; open desktop</string>
<string name="desktop_remove_title">Uninstall desktop</string>
<string name="desktop_remove_text">Remove the desktop and its bundled apps. Keep games, added apps, settings and shared libraries.</string>
<string name="desktop_removing">Removing desktop</string>
<string name="desktop_removed">Desktop removed</string>
<string name="desktop_cleaning_legacy">Removing old LXQt files</string>
<string name="desktop_removal_unknown">Update DroidDeck before removing this desktop version.</string>
<string name="content_linux_desktop">Linux desktop</string>
<string name="content_linux_desktop_hint">KDE Plasma, Firefox and your emulators</string>
<string name="games_empty">Games you install in Steam show up here after the session.</string>
@@ -23,6 +23,10 @@ class DesktopCatalogTest {
assertFalse(DesktopCatalog.desktopInstalled(root))
marker.writeText("${BuildConfig.DESKTOP_KDE_TAG}\n")
assertTrue(DesktopCatalog.desktopInstalled(root))
val pending = File(root, DesktopRemoval.PENDING).apply { writeText(BuildConfig.DESKTOP_KDE_TAG) }
assertFalse(DesktopCatalog.desktopInstalled(root))
pending.delete()
assertTrue(DesktopCatalog.desktopInstalled(root))
kwin.delete()
assertFalse(DesktopCatalog.desktopInstalled(root))
}
@@ -0,0 +1,79 @@
package com.droiddeck.launcher.runtime
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
class DesktopFileRemovalTest {
private lateinit var dir: File
private lateinit var root: File
@Before fun setup() { dir = Files.createTempDirectory("desktop-removal").toFile(); root = File(dir, "linuxfs").apply { mkdirs() } }
@After fun cleanup() { RuntimeFileTree.delete(dir, null) }
private fun file(path: String, data: String) = File(root, path).apply { parentFile.mkdirs(); writeText(data) }
private fun entry(path: String, file: File) = DesktopFileRemoval.Entry("F", file.length(), DesktopFileRemoval.sha(file), path)
@Test fun removesUnchangedFilesAndEmptyParentsButPreservesUserDataAndSharedFiles() {
val desktop = file("usr/share/plasma/only/asset", "desktop")
val shared = file("usr/lib/shared.so", "shared")
val custom = file("usr/share/plasma/custom", "user")
val home = file("root/.local/share/Steam/game", "game")
val result = DesktopFileRemoval.remove(root, listOf(entry("usr/share/plasma/only/asset", desktop), entry("usr/lib/shared.so", shared), entry("root/.local/share/Steam/game", home)), setOf("usr/lib/shared.so"))
assertEquals(1, result.removed)
assertEquals(7L, result.bytes)
assertFalse(desktop.parentFile.exists())
assertEquals("shared", shared.readText())
assertEquals("user", custom.readText())
assertEquals("game", home.readText())
}
@Test fun preservesChangesIncludingSameLengthEditsAndSymlinkReplacement() {
val file = file("usr/bin/desktop", "before")
val expected = entry("usr/bin/desktop", file)
file.writeText("edited")
val target = file("root/private", "data")
val link = File(root, "usr/bin/link")
Files.createSymbolicLink(link.toPath(), target.toPath())
val result = DesktopFileRemoval.remove(root, listOf(expected, DesktopFileRemoval.Entry("L", 0, "/usr/bin/original", "usr/bin/link")), emptySet())
assertEquals(0, result.removed)
assertEquals("edited", file.readText())
assertTrue(Files.isSymbolicLink(link.toPath()))
}
@Test fun neverFollowsParentsEvenWhenTheyPointInsideRootAndOnlyUnlinksRecordedLinks() {
val keep = file("root/private/asset", "desktop")
File(root, "usr/share").mkdirs()
Files.createSymbolicLink(File(root, "usr/share/plasma").toPath(), File(root, "root/private").toPath())
val dangling = File(root, "usr/share/dangling")
Files.createSymbolicLink(dangling.toPath(), File("/missing").toPath())
val result = DesktopFileRemoval.remove(root, listOf(entry("usr/share/plasma/asset", keep), DesktopFileRemoval.Entry("L", 0, "/missing", "usr/share/dangling")), emptySet())
assertEquals(1, result.removed)
assertEquals("desktop", keep.readText())
assertFalse(Files.exists(dangling.toPath(), java.nio.file.LinkOption.NOFOLLOW_LINKS))
}
@Test fun rejectsTraversalAbsolutePathsAndUserHome() {
for (path in listOf("../etc/x", "/etc/x", "usr/../../x", "usr/./x", "usr//x", "root/x", "opt/appimages/x"))
assertNull(path, DesktopFileRemoval.safeFile(root, path))
}
@Test fun interruptedRemovalIsIdempotentAndRetriesRemainingFiles() {
val one = file("usr/bin/one", "one")
val two = file("usr/bin/two", "two")
val entries = listOf(entry("usr/bin/one", one), entry("usr/bin/two", two))
try { DesktopFileRemoval.remove(root, entries.asSequence().constrainOnce().asIterable(), emptySet()) { throw IllegalStateException("process ended") }; fail() }
catch (_: IllegalStateException) { }
assertFalse(one.exists())
assertTrue(two.exists())
assertEquals(1, DesktopFileRemoval.remove(root, entries, emptySet()).removed)
assertEquals(0, DesktopFileRemoval.remove(root, entries, emptySet()).removed)
}
@Test fun readsOnlyRuntimePackageFilesAndRefusesMissingOwnership() {
try { DesktopFileRemoval.runtimeFiles(root); fail() } catch (_: java.io.IOException) { }
file("var/lib/pacman/local/base-1/files", "%FILES%\nusr/\nusr/bin/\nusr/bin/gamescope\n\n%BACKUP%\netc/config\n")
assertEquals(setOf("usr/bin/gamescope"), DesktopFileRemoval.runtimeFiles(root))
}
}
@@ -0,0 +1,63 @@
package com.droiddeck.launcher.runtime
import com.droiddeck.launcher.session.SessionPhase
import com.droiddeck.launcher.session.SessionState
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import org.junit.Assert.*
import org.junit.Test
class DesktopMaintenanceTest {
@Test(timeout = 15000) fun reservationBlocksRuntimeChangesAndFollowersSurviveScreenRecreation() {
SessionState.running = false
SessionState.phase = SessionPhase.IDLE
val operation = LinuxRuntimeInstaller.beginMaintenance(null, "Removing desktop")!!
assertTrue(LinuxRuntimeInstaller.isBusy())
assertTrue(LinuxRuntimeInstaller.isMaintaining())
assertFalse(LinuxRuntimeInstaller.isRemoving())
assertFalse(LinuxRuntimeInstaller.install(null, null, null))
assertNull(LinuxRuntimeInstaller.beginMaintenance(null, "other"))
assertNull(LinuxRuntimeInstaller.beginUninstall(null, java.io.File("unused")))
val entered = CountDownLatch(1)
val release = CountDownLatch(1)
val worker = Thread {
assertTrue(operation.run(null) { progress ->
progress.onProgress("Removing desktop", 30)
entered.countDown()
assertTrue(release.await(5, TimeUnit.SECONDS))
progress.onProgress("Desktop removed", 100)
null
})
}
worker.start()
try {
assertTrue(entered.await(5, TimeUnit.SECONDS))
val following = CountDownLatch(1)
val follower = Thread {
assertEquals(true, LinuxRuntimeInstaller.attach { stage, _ ->
if (stage == "Removing desktop") following.countDown()
})
}
follower.start()
assertTrue(following.await(5, TimeUnit.SECONDS))
release.countDown()
worker.join(5000); follower.join(5000)
assertFalse(LinuxRuntimeInstaller.isBusy())
} finally { release.countDown(); worker.join(5000) }
}
@Test(timeout = 15000) fun refusesActiveOrStartingSessionsAndReportsFailureWithoutRetainingTheReservation() {
SessionState.phase = SessionPhase.STARTING_GUEST
assertNull(LinuxRuntimeInstaller.beginMaintenance(null, "Removing desktop"))
SessionState.phase = SessionPhase.IDLE
SessionState.running = true
assertNull(LinuxRuntimeInstaller.beginMaintenance(null, "Removing desktop"))
SessionState.running = false
val operation = LinuxRuntimeInstaller.beginMaintenance(null, "Removing desktop")!!
assertFalse(operation.run(null) { "cannot unlink" })
assertEquals("cannot unlink", LinuxRuntimeInstaller.removalError())
assertFalse(LinuxRuntimeInstaller.isBusy())
assertTrue(LinuxRuntimeInstaller.beginMaintenance(null, "retry")!!.run(null) { null })
assertNull(LinuxRuntimeInstaller.removalError())
}
}
+25
View File
@@ -33,3 +33,28 @@ The desktop workflow replaces the same-version `kwin_wayland` executable and `li
comes from the signature-checked Arch packages. `desktop-kde.kwin.txt` records the source, patch,
executable, library and build package hashes. Corresponding source is
https://download.kde.org/stable/plasma/6.7.5/kwin-6.7.5.tar.xz plus the patch in this repository.
The inventories use `.gzip` rather than `.gz`: Android's asset packer expands `.gz` and strips
its suffix. The shared local/CI runtime payload check verifies the exact APK names and bytes.
Desktop removal uses `app/src/main/assets/desktop-removal/`, generated by `removal-manifests.py`
from the checksum-pinned LXQt r1, KDE r1/r2 and emulator r1 archives. KDE candidates are the files
of the desktop applications/themes in `seeds.txt` (their source package hashes verified against
`desktop-kde.packages.txt`) plus our overlay. Shared dependencies, fonts, Xwayland and Qt Wayland
remain available to Steam, native apps and emulators. Regenerate the inventories and add the new
version to `versions.txt` when publishing another desktop pin; unknown versions cannot be removed.
Removal checks file size and SHA-256, or the exact symlink target, before unlinking. Base-runtime
pacman files and emulator bundle paths are protected even when they overlap an application.
It never follows parent symlinks or recursively deletes directories. Home, added apps, custom
files and modified package files stay. A pending marker disables the installed state and allows
retry after interruption. Installing the desktop again repairs its package files and clears it.
The old LXQt applications are cleaned after KDE installation and before the next guest launch on
an already-upgraded runtime, protecting both KDE inventories as well as the runtime and emulators.
Only files owned by `lxqt-applications.txt`'s application packages are candidates; dependencies
stay even when the old runtime's pacman database does not record them. In particular, gamescope
still needs the old package's libseat. The source LXQt application archives are the versions in
the published `steamdeck-desktop-r1/desktop.packages.txt`; verify their `.sig` files with
`gpgv --keyring tools/desktop-kde/archlinuxarm-builder.gpg` and the Build System fingerprint used
by `build.sh`. Put their filenames in `selected.txt` in the LXQt cache. `sources.json` records
their SHA-256 values; file identities still come from the checksum-pinned published desktop.
+17
View File
@@ -0,0 +1,17 @@
# Application packages from steamdeck-desktop-r1's desktop.packages.txt.
# Keep dependencies and shared apps/icons (Firefox, mpv, Breeze, xdg-utils and portals).
labwc
lxqt-panel
lxqt-config
lxqt-runner
lxqt-session
lxqt-wayland-session
lxqt-themes
lxqt-qtplugin
pcmanfm-qt
qterminal
featherpad
lximage-qt
papirus-icon-theme
swaybg
wlr-randr
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""Generate removal inventories from checksum-pinned published archives.
Usage: removal-manifests.py <LXQt tar.zst> <KDE r1 tar.zst> <KDE r2 tar.zst>
<emulators tar.zst> <verified KDE package cache> <output dir>
<verified LXQt package cache>
Removal includes desktop applications/themes and our overlay, not their shared dependencies.
The KDE cache is verified against the published desktop-kde.packages.txt before use.
The LXQt cache contains the signed application packages listed by desktop.packages.txt;
verify their Arch Linux ARM Build System signatures before generating the inventory.
"""
import gzip
import hashlib
import json
import pathlib
import subprocess
import sys
import tarfile
PINS = [
('lxqt-r1', 'c2bb860b3b1688287451e2495be5db313ec07f8c8af501dfac2768a9f417bc86'),
('desktop-kde-r1', '55747e75d0ec055ab7ea1e92a757f3be227cc3205bb9101f5b53cadef6318579'),
('desktop-kde-r2', '9ba832677b498654136c69d8c8330434d17e4d53cd099acac9bde3ba429b35bf'),
('emulators-r1', 'a1f4806a9663d28f424b4cab1277e0ede80a92e69de5bc063cdc8b45af220164'),
]
SHARED_SEEDS = {'xorg-xwayland', 'qt6-wayland', 'wayland-utils', 'noto-fonts', 'noto-fonts-emoji', 'xdg-utils'}
def sha(file):
with open(file, 'rb') as stream:
return hashlib.file_digest(stream, 'sha256').hexdigest()
def inventory(archive, digest):
if sha(archive) != digest:
raise ValueError(f'{archive}: checksum differs from the published pin')
entries = {}
process = subprocess.Popen(['zstd', '-dc', str(archive)], stdout=subprocess.PIPE)
try:
with tarfile.open(fileobj=process.stdout, mode='r|') as tar:
for entry in tar:
path = entry.name.removeprefix('./').rstrip('/')
assert not any(c in path for c in '\t\n\r') and '..' not in path.split('/') and not path.startswith('/')
if entry.isfile():
entries[path] = f'F\t{entry.size}\t{hashlib.file_digest(tar.extractfile(entry), "sha256").hexdigest()}\t{path}'
elif entry.issym():
assert not any(c in entry.linkname for c in '\t\n\r')
entries[path] = f'L\t0\t{entry.linkname}\t{path}'
elif entry.islnk():
target = entry.linkname.removeprefix('./')
record = entries[target].split('\t', 3)
entries[path] = '\t'.join(record[:3] + [path])
finally:
process.stdout.close()
if process.wait() != 0:
raise ValueError('zstd failed')
return entries
def write(out, name, records):
with open(out / name, 'wb') as stream:
with gzip.GzipFile(fileobj=stream, mode='wb', mtime=0, filename='') as gz:
gz.write(('\n'.join(sorted(set(records))) + '\n').encode())
def main():
archives = [pathlib.Path(p) for p in sys.argv[1:5]]
cache, out, legacy_cache = map(pathlib.Path, sys.argv[5:8])
here = pathlib.Path(__file__).resolve().parent
seeds = {line.strip() for line in (here / 'seeds.txt').read_text().splitlines() if line.strip() and not line.startswith('#')} - SHARED_SEEDS
# Published source package hashes; the caller places this beside the verified cache.
packages = dict(line.split() for line in (cache / 'desktop-kde.packages.txt').read_text().splitlines())
selected = set()
found = set()
for filename, digest in packages.items():
package = filename.rsplit('-', 3)[0]
if package not in seeds:
continue
archive = cache / filename
if sha(archive) != digest:
raise ValueError(f'{filename}: source package checksum mismatch')
with tarfile.open(archive) as tar:
selected.update(e.name.removeprefix('./').rstrip('/') for e in tar if not e.isdir())
found.add(package)
assert found == seeds, f'missing desktop packages: {seeds - found}'
selected.update(str(p.relative_to(here / 'overlay')) for p in (here / 'overlay').rglob('*') if p.is_file())
inventories = [inventory(p, pin[1]) for p, pin in zip(archives, PINS)]
out.mkdir(parents=True, exist_ok=True)
# The old runtime's pacman database omits some gamescope dependencies (e.g. libseat).
# Own only the LXQt application packages, never their dependency closure.
legacy_seeds = {line.strip() for line in (here / 'lxqt-applications.txt').read_text().splitlines()
if line.strip() and not line.startswith('#')}
legacy_selected = set()
legacy_sources = {}
legacy_found = set()
for filename in (legacy_cache / 'selected.txt').read_text().splitlines():
package = filename.rsplit('-', 3)[0]
if package not in legacy_seeds:
continue
archive = legacy_cache / filename
with tarfile.open(archive) as tar:
legacy_selected.update(e.name.removeprefix('./').rstrip('/') for e in tar if not e.isdir())
legacy_sources[filename] = sha(archive)
legacy_found.add(package)
assert legacy_found == legacy_seeds, f'missing LXQt applications: {legacy_seeds - legacy_found}'
legacy_selected.update({'usr/local/bin/steamdeck-desktop', 'usr/local/bin/steamdeck-steam'})
write(out, 'lxqt-r1.tsv.gzip', [record for path, record in inventories[0].items() if path in legacy_selected])
write(out, 'kde-remove.tsv.gzip', [record for inv in inventories[1:3] for path, record in inv.items() if path in selected])
write(out, 'kde-keep.txt.gzip', [path for inv in inventories[1:3] for path in inv])
write(out, 'emulators-keep.txt.gzip', inventories[3])
(out / 'versions.txt').write_text('desktop-kde-r1\ndesktop-kde-r2\n')
(out / 'sources.json').write_text(json.dumps({'archives': dict(PINS), 'desktopApplications': {n: h for n, h in packages.items() if n.rsplit('-', 3)[0] in seeds}, 'legacyApplications': legacy_sources}, indent=2) + '\n')
for name, digest in PINS:
print(name, digest)
print('KDE removable paths', sum(path in selected for path in inventories[2]))
print('KDE removable bytes', sum(int(record.split('\t')[1]) for path, record in inventories[2].items() if path in selected))
print('LXQt removable paths', sum(path in legacy_selected for path in inventories[0]))
if __name__ == '__main__':
main()
+12 -2
View File
@@ -1,5 +1,6 @@
"""Attest prepared runtime inputs and reject stale local or CI APK payloads."""
import hashlib
import gzip
import json
from pathlib import Path
import subprocess
@@ -15,7 +16,7 @@ SOURCE_PATHS = ['app/build.gradle', 'app/src/main/cpp/fakeinput_steam.cpp', 'app
'tools/linuxfs', 'tools/proot', 'tools/aaudio-sink', 'tools/directaudio',
'tools/gamescope/release.env', 'tools/droiddeck-esync/release.env',
'tools/mangoapp', 'tools/msitools', 'tools/build_local.sh',
'tools/release/runtime_inputs.py']
'tools/release/runtime_inputs.py', 'app/src/main/assets/desktop-removal']
def digest(path):
@@ -39,7 +40,7 @@ def sources(root):
def outputs(root):
result = {}
for tree in ['linuxfs', 'directaudio', 'droiddeck-esync']:
for tree in ['linuxfs', 'directaudio', 'droiddeck-esync', 'desktop-removal']:
base = root / 'app/src/main/assets' / tree
for path in base.rglob('*'):
if path.is_file() and not any(part.startswith('.') for part in path.relative_to(base).parts):
@@ -69,6 +70,9 @@ def required():
paths += ['assets/droiddeck-esync/index.json', 'assets/droiddeck-esync/index.json.sig',
'lib/arm64-v8a/libproot.so', 'lib/arm64-v8a/libproot-loader.so',
'lib/arm64-v8a/libdirectaudiorelay.so', 'assets/pulseaudio.tzst']
paths += ['assets/desktop-removal/' + name for name in
['lxqt-r1.tsv.gzip', 'kde-remove.tsv.gzip', 'kde-keep.txt.gzip',
'emulators-keep.txt.gzip', 'versions.txt', 'sources.json']]
return paths
@@ -84,6 +88,12 @@ def record(root=ROOT):
missing = [name for name in required() if name not in paths or not (root / paths[name]).is_file()]
if missing:
raise ValueError('Missing runtime inputs:\n' + '\n'.join(missing))
for name, path in paths.items():
if name.startswith('assets/desktop-removal/') and name.endswith('.gzip'):
try:
gzip.decompress((root / path).read_bytes())
except (OSError, EOFError) as error:
raise ValueError('Invalid desktop removal inventory: ' + name) from error
# Check inside the compressed payload, where a plain Gradle build used to ship no sinks.
process = subprocess.Popen(['zstd', '-dc', str(root / paths['assets/pulseaudio.tzst'])], stdout=subprocess.PIPE)
try:
+25
View File
@@ -0,0 +1,25 @@
import gzip
from pathlib import Path
import unittest
ASSETS = Path(__file__).resolve().parents[2] / 'app/src/main/assets/desktop-removal'
class DesktopRemovalInventoryTest(unittest.TestCase):
def test_legacy_inventory_removes_the_shell_and_retains_gamescope_dependencies(self):
records = gzip.decompress((ASSETS / 'lxqt-r1.tsv.gzip').read_bytes()).decode().splitlines()
paths = {record.split('\t', 3)[3] for record in records}
self.assertIn('usr/bin/lxqt-session', paths)
self.assertIn('usr/bin/labwc', paths)
self.assertIn('usr/local/bin/steamdeck-desktop', paths)
# The runtime pacman database did not protect libseat on the Thor. Removing it
# made gamescope and every Steam/native session exit 127 after desktop cleanup.
self.assertNotIn('usr/lib/libseat.so.1', paths)
self.assertNotIn('usr/lib/libseat.so', paths)
self.assertFalse(any(p.startswith('usr/lib/libSDL2') for p in paths))
self.assertFalse(any(p.startswith(('root/', 'opt/', 'var/')) for p in paths))
if __name__ == '__main__':
unittest.main()
+28 -2
View File
@@ -1,4 +1,5 @@
import importlib.util
import gzip
import io
from pathlib import Path
import subprocess
@@ -22,7 +23,7 @@ class RuntimeInputsTest(unittest.TestCase):
for name in runtime.required():
path = self.root / paths.get(name, 'app/src/main/' + name)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(b'prepared runtime')
path.write_bytes(gzip.compress(b'inventory') if path.suffix == '.gzip' else b'prepared runtime')
self.source = self.root / 'tools/linuxfs/preload/test.c'
self.source.parent.mkdir(parents=True)
self.source.write_text('current source')
@@ -73,4 +74,29 @@ class RuntimeInputsTest(unittest.TestCase):
def test_complete_current_payload_passes(self):
runtime.record(self.root)
runtime.verify(self.root)
data = runtime.verify(self.root)
apk = self.root / 'current.apk'
with ZipFile(apk, 'w') as package:
for name, output in data['outputs'].items():
package.writestr(name, (self.root / output['path']).read_bytes())
runtime.check_apk(apk, self.root)
def test_android_expanding_or_renaming_a_compressed_inventory_is_rejected(self):
runtime.record(self.root)
data = runtime.verify(self.root)
apk = self.root / 'expanded.apk'
with ZipFile(apk, 'w') as package:
for name, output in data['outputs'].items():
content = (self.root / output['path']).read_bytes()
if name.endswith('.gzip'):
name = name.removesuffix('.gzip')
content = gzip.decompress(content)
package.writestr(name, content)
with self.assertRaisesRegex(ValueError, 'APK missing assets/desktop-removal/'):
runtime.check_apk(apk, self.root)
def test_malformed_removal_inventory_cannot_be_attested(self):
path = self.root / 'app/src/main/assets/desktop-removal/kde-remove.tsv.gzip'
path.write_bytes(b'not a gzip stream')
with self.assertRaisesRegex(ValueError, 'Invalid desktop removal inventory'):
runtime.record(self.root)