mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-12 03:21:08 +00:00
feat(desktop): add desktop uninstall and legacy LXQt cleanup (#493)
This commit is contained in:
1 parent
79d92111e4
commit
67a9ea2f42
28 files changed
+728
-25
No files matched your search
@@ -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.
Binary file not shown.
Binary file not shown.
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,
|
||||
|
||||
@@ -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 & 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())
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user