diff --git a/app/build.gradle b/app/build.gradle index 26bb29762..6752f032b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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('#') } diff --git a/app/src/main/assets/desktop-removal/emulators-keep.txt.gzip b/app/src/main/assets/desktop-removal/emulators-keep.txt.gzip new file mode 100644 index 000000000..45eb8fc66 Binary files /dev/null and b/app/src/main/assets/desktop-removal/emulators-keep.txt.gzip differ diff --git a/app/src/main/assets/desktop-removal/kde-keep.txt.gzip b/app/src/main/assets/desktop-removal/kde-keep.txt.gzip new file mode 100644 index 000000000..99614abb6 Binary files /dev/null and b/app/src/main/assets/desktop-removal/kde-keep.txt.gzip differ diff --git a/app/src/main/assets/desktop-removal/kde-remove.tsv.gzip b/app/src/main/assets/desktop-removal/kde-remove.tsv.gzip new file mode 100644 index 000000000..3eae56100 Binary files /dev/null and b/app/src/main/assets/desktop-removal/kde-remove.tsv.gzip differ diff --git a/app/src/main/assets/desktop-removal/lxqt-r1.tsv.gzip b/app/src/main/assets/desktop-removal/lxqt-r1.tsv.gzip new file mode 100644 index 000000000..ea43cd0b6 Binary files /dev/null and b/app/src/main/assets/desktop-removal/lxqt-r1.tsv.gzip differ diff --git a/app/src/main/assets/desktop-removal/sources.json b/app/src/main/assets/desktop-removal/sources.json new file mode 100644 index 000000000..c185153b8 --- /dev/null +++ b/app/src/main/assets/desktop-removal/sources.json @@ -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" + } +} diff --git a/app/src/main/assets/desktop-removal/versions.txt b/app/src/main/assets/desktop-removal/versions.txt new file mode 100644 index 000000000..b748992c0 --- /dev/null +++ b/app/src/main/assets/desktop-removal/versions.txt @@ -0,0 +1,2 @@ +desktop-kde-r1 +desktop-kde-r2 diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index 435f535a3..9d613e521 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -139,6 +139,7 @@ class MainActivity : ComponentActivity() { private var pkgStage by mutableStateOf(null) private var pkgPercent by mutableIntStateOf(-1) private var desktopInstalled by mutableStateOf(false) + private var desktopPresent by mutableStateOf(false) private var offlineAccount by mutableStateOf(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 -> diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 96a1b9d4c..0ee7ff901 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -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() diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/DesktopCatalog.kt b/app/src/main/java/com/droiddeck/launcher/runtime/DesktopCatalog.kt index 1da904983..761b6e977 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/DesktopCatalog.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/DesktopCatalog.kt @@ -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) diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/DesktopFileRemoval.kt b/app/src/main/java/com/droiddeck/launcher/runtime/DesktopFileRemoval.kt new file mode 100644 index 000000000..063a5c9a9 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/runtime/DesktopFileRemoval.kt @@ -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, protected: Set, 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() + 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 = 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") + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/DesktopRemoval.kt b/app/src/main/java/com/droiddeck/launcher/runtime/DesktopRemoval.kt new file mode 100644 index 000000000..3f3b493e5 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/runtime/DesktopRemoval.kt @@ -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) +} diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntimeInstaller.java b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntimeInstaller.java index 302912924..305fd34f7 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntimeInstaller.java +++ b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntimeInstaller.java @@ -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) { diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeInstallService.kt b/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeInstallService.kt index 639726bfb..aef352baa 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeInstallService.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeInstallService.kt @@ -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) diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt index 6da8cfbf6..954d91b31 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt @@ -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") diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt index 437dd5091..6283d5aba 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -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) { diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt index fcbefd1a0..925b0a883 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt @@ -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) } } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt index cf3558568..7bc0cb1cf 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt @@ -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, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 29f00e457..b827e16a4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -896,6 +896,12 @@ That page is gone. Pick a section on the left. Open desktop Install & open desktop + Uninstall desktop + Remove the desktop and its bundled apps. Keep games, added apps, settings and shared libraries. + Removing desktop + Desktop removed + Removing old LXQt files + Update DroidDeck before removing this desktop version. Linux desktop KDE Plasma, Firefox and your emulators Games you install in Steam show up here after the session. diff --git a/app/src/test/java/com/droiddeck/launcher/runtime/DesktopCatalogTest.kt b/app/src/test/java/com/droiddeck/launcher/runtime/DesktopCatalogTest.kt index f7586544d..f59263ac5 100644 --- a/app/src/test/java/com/droiddeck/launcher/runtime/DesktopCatalogTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/runtime/DesktopCatalogTest.kt @@ -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)) } diff --git a/app/src/test/java/com/droiddeck/launcher/runtime/DesktopFileRemovalTest.kt b/app/src/test/java/com/droiddeck/launcher/runtime/DesktopFileRemovalTest.kt new file mode 100644 index 000000000..9040171c4 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/runtime/DesktopFileRemovalTest.kt @@ -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)) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/runtime/DesktopMaintenanceTest.kt b/app/src/test/java/com/droiddeck/launcher/runtime/DesktopMaintenanceTest.kt new file mode 100644 index 000000000..55b46fe6f --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/runtime/DesktopMaintenanceTest.kt @@ -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()) + } +} diff --git a/tools/desktop-kde/README.md b/tools/desktop-kde/README.md index 7521692a5..ced96147e 100644 --- a/tools/desktop-kde/README.md +++ b/tools/desktop-kde/README.md @@ -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. diff --git a/tools/desktop-kde/lxqt-applications.txt b/tools/desktop-kde/lxqt-applications.txt new file mode 100644 index 000000000..812715567 --- /dev/null +++ b/tools/desktop-kde/lxqt-applications.txt @@ -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 diff --git a/tools/desktop-kde/removal-manifests.py b/tools/desktop-kde/removal-manifests.py new file mode 100644 index 000000000..86ed74e69 --- /dev/null +++ b/tools/desktop-kde/removal-manifests.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""Generate removal inventories from checksum-pinned published archives. + +Usage: removal-manifests.py + + +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() diff --git a/tools/release/runtime_inputs.py b/tools/release/runtime_inputs.py index 04f91c8c8..78deb433c 100644 --- a/tools/release/runtime_inputs.py +++ b/tools/release/runtime_inputs.py @@ -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: diff --git a/tools/tests/test_desktop_removal.py b/tools/tests/test_desktop_removal.py new file mode 100644 index 000000000..674b9643f --- /dev/null +++ b/tools/tests/test_desktop_removal.py @@ -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() diff --git a/tools/tests/test_runtime_inputs.py b/tools/tests/test_runtime_inputs.py index 030f25432..b2f2f02a5 100644 --- a/tools/tests/test_runtime_inputs.py +++ b/tools/tests/test_runtime_inputs.py @@ -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)