From 3f2aea4d081ae87c422026ec6d6a15b1f6815b3a Mon Sep 17 00:00:00 2001 From: K11MCH1 <81425424+K11MCH1@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:56:39 +0900 Subject: [PATCH 01/59] Add File Manager Return and Exit navigation Description: - Add an Exit button at the right end of the File Manager toolbar. It finishes the File Manager activity from any directory. - Remember the directory supplied when Game files or Proton prefix opens the File Manager. Show a Return to start button in the toolbar for those launches, and use it to reopen that directory after the user switches storage locations or browses elsewhere. - When returning, restore the starting storage root and close search or favorites views. Show a message if the starting directory is no longer available. - Add accessible labels for both buttons while retaining the existing toolbar actions and File Manager styling. Why it was needed: - Exiting previously required repeatedly navigating back through directories to the original location. - Switching from the game's initial directory to Internal storage offered no direct route back, leaving users to find the path manually through Linux system storage. --- .../launcher/files/FileManagerActivity.kt | 2 +- .../launcher/files/FileManagerScreen.kt | 39 +++++++++++++++++-- app/src/main/res/values/strings.xml | 3 ++ 3 files changed, 39 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/files/FileManagerActivity.kt b/app/src/main/java/com/droiddeck/launcher/files/FileManagerActivity.kt index 27cb9dbce..1425a78e7 100644 --- a/app/src/main/java/com/droiddeck/launcher/files/FileManagerActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/files/FileManagerActivity.kt @@ -29,7 +29,7 @@ class FileManagerActivity : ComponentActivity() { setContent { DroidDeckTheme(appScale = com.droiddeck.launcher.core.AppUiPrefs.scale(this)) { Surface(modifier = Modifier.fillMaxSize().systemBarsPadding(), color = MaterialTheme.colorScheme.background) { - FileManagerScreen(initialDir = initialDir) + FileManagerScreen(initialDir = initialDir, onExit = { finish() }) } } } diff --git a/app/src/main/java/com/droiddeck/launcher/files/FileManagerScreen.kt b/app/src/main/java/com/droiddeck/launcher/files/FileManagerScreen.kt index 03b4aa277..6f02927e9 100644 --- a/app/src/main/java/com/droiddeck/launcher/files/FileManagerScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/files/FileManagerScreen.kt @@ -28,6 +28,8 @@ import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ExitToApp +import androidx.compose.material.icons.automirrored.filled.KeyboardReturn import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ContentPaste import androidx.compose.material.icons.filled.CreateNewFolder @@ -104,6 +106,7 @@ fun FileManagerScreen( initialDir: File? = null, pickerTitle: String? = null, onPick: ((File) -> Unit)? = null, + onExit: (() -> Unit)? = null, ) { val context = LocalContext.current val scope = rememberCoroutineScope() @@ -117,12 +120,14 @@ fun FileManagerScreen( val pickPrefs = remember { context.getSharedPreferences("file_manager", android.content.Context.MODE_PRIVATE) } val browsePrefs = pickPrefs - val rootDir = remember { + val rootDir = remember(initialDir) { initialDir?.takeIf { it.isDirectory } ?: File("/storage/emulated/0") } - + val launchDir = remember(initialDir, pickMode) { + initialDir?.takeIf { it.isDirectory && !pickMode } + } var currentDir by remember { mutableStateOf(rootDir) } - var currentRoot by remember { + val initialRoot = remember(rootDir) { val abs = rootDir.absolutePath val internal = "/storage/emulated/0" val vol = when { @@ -138,8 +143,9 @@ fun FileManagerScreen( ?: rootDir } } - mutableStateOf(vol) + vol } + var currentRoot by remember { mutableStateOf(initialRoot) } var entries by remember { mutableStateOf(listOf()) } var selectedEntry by remember { mutableStateOf(null) } var showMenuFor by remember { mutableStateOf(null) } @@ -208,6 +214,19 @@ fun FileManagerScreen( loadDirectory(dir) } + fun returnToStart() { + val dir = launchDir ?: return + if (!dir.isDirectory) { + Toast.makeText(context, R.string.fm_start_unavailable, Toast.LENGTH_SHORT).show() + return + } + showFavorites = false + showSearch = false + searchQuery = "" + currentRoot = initialRoot + loadDirectory(dir) + } + LaunchedEffect(Unit) { loadDirectory(rootDir) } BackHandler(enabled = showFavorites || currentDir != currentRoot) { @@ -669,6 +688,13 @@ fun FileManagerScreen( ) } + if (launchDir != null) { + IconButton(onClick = { returnToStart() }) { + Icon(Icons.AutoMirrored.Filled.KeyboardReturn, stringResource(R.string.fm_return_to_start), tint = MaterialTheme.colorScheme.primary) + } + if (LocalConfiguration.current.screenWidthDp >= 700) Spacer(Modifier.width(56.dp)) + } + if (!showFavorites) { if (!pickMode) { OutlinedButton( @@ -744,6 +770,11 @@ fun FileManagerScreen( Icon(Icons.Filled.StarBorder, stringResource(R.string.fm_show_favorites), tint = MaterialTheme.colorScheme.onSurfaceVariant) } } + if (!pickMode && onExit != null) { + IconButton(onClick = onExit) { + Icon(Icons.AutoMirrored.Filled.ExitToApp, stringResource(R.string.fm_exit), tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + } } if (showSearch && !showFavorites) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 830e66378..e64eb7e7d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1543,6 +1543,9 @@ Skip Select this folder Back + Exit + Return to start + Starting folder is no longer available %1$s is mounted but not readable right now List view Grid view From 1eb1e154ab75477fddb66bfb79e0f10d81a48d5d Mon Sep 17 00:00:00 2001 From: K11MCH1 <81425424+K11MCH1@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:27:07 +0900 Subject: [PATCH 02/59] Confirm navigation during File Manager operations Description: - Ask for confirmation when Exit, Return to start, Android Back, or the parent-folder controls are used while a file operation is active. - Accept cancels the active job and waits for it to finish before navigating; Decline keeps the File Manager open and lets the operation continue. - Track single-file operations as jobs and handle Back from the file picker root through the same navigation guard. - Translate the existing Exit, Return, and unavailable-folder labels plus the new confirmation dialog in all 32 supported locale resource sets. Why it was needed: - Leaving the File Manager while a batch copy, move, or delete is running cancels its composition-bound coroutine, which can stop the batch after only some files are processed without a clear result. - The new File Manager controls previously appeared in English when the app was set to another supported language. --- .../launcher/files/FileManagerScreen.kt | 101 ++++++++++++++---- .../launcher/files/FilePickerActivity.kt | 1 + app/src/main/res/values-ar/strings.xml | 7 ++ app/src/main/res/values-b+es+419/strings.xml | 7 ++ app/src/main/res/values-bg/strings.xml | 7 ++ app/src/main/res/values-cs/strings.xml | 7 ++ app/src/main/res/values-da/strings.xml | 7 ++ app/src/main/res/values-de/strings.xml | 7 ++ app/src/main/res/values-el/strings.xml | 7 ++ app/src/main/res/values-es/strings.xml | 7 ++ app/src/main/res/values-fi/strings.xml | 7 ++ app/src/main/res/values-fr/strings.xml | 7 ++ app/src/main/res/values-hi/strings.xml | 7 ++ app/src/main/res/values-hu/strings.xml | 7 ++ app/src/main/res/values-in/strings.xml | 7 ++ app/src/main/res/values-it/strings.xml | 7 ++ app/src/main/res/values-ja/strings.xml | 7 ++ app/src/main/res/values-ko/strings.xml | 7 ++ app/src/main/res/values-ms/strings.xml | 7 ++ app/src/main/res/values-nb/strings.xml | 7 ++ app/src/main/res/values-nl/strings.xml | 7 ++ app/src/main/res/values-pl/strings.xml | 7 ++ app/src/main/res/values-pt-rPT/strings.xml | 7 ++ app/src/main/res/values-pt/strings.xml | 7 ++ app/src/main/res/values-ro/strings.xml | 7 ++ app/src/main/res/values-ru/strings.xml | 7 ++ app/src/main/res/values-sv/strings.xml | 7 ++ app/src/main/res/values-th/strings.xml | 7 ++ app/src/main/res/values-tr/strings.xml | 7 ++ app/src/main/res/values-uk/strings.xml | 7 ++ app/src/main/res/values-vi/strings.xml | 7 ++ app/src/main/res/values-zh-rCN/strings.xml | 7 ++ app/src/main/res/values-zh-rHK/strings.xml | 7 ++ app/src/main/res/values-zh-rTW/strings.xml | 7 ++ app/src/main/res/values/strings.xml | 4 + 35 files changed, 309 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/files/FileManagerScreen.kt b/app/src/main/java/com/droiddeck/launcher/files/FileManagerScreen.kt index 6f02927e9..2ce9a1c47 100644 --- a/app/src/main/java/com/droiddeck/launcher/files/FileManagerScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/files/FileManagerScreen.kt @@ -92,11 +92,14 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.droiddeck.launcher.session.SessionPrefs import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.File import java.util.Date +private enum class FileManagerNavigation { EXIT, RETURN_TO_START, BACK } + @OptIn(ExperimentalMaterial3Api::class) @Composable fun FileManagerScreen( @@ -156,8 +159,10 @@ fun FileManagerScreen( var pendingConflict by remember { mutableStateOf(null) } var conflictChoice by remember { mutableStateOf(null) } var conflictApplyToAll by remember { mutableStateOf(false) } - var operationJob by remember { mutableStateOf(null) } - var cancelledJob by remember { mutableStateOf(null) } + var operationJob by remember { mutableStateOf(null) } + var cancelledJob by remember { mutableStateOf(null) } + var pendingNavigation by remember { mutableStateOf(null) } + var cancellingNavigation by remember { mutableStateOf(false) } var pendingBulkDelete by remember { mutableStateOf>(emptyList()) } var searchQuery by remember { mutableStateOf("") } val visibleEntries = if (searchQuery.isBlank()) entries @@ -183,7 +188,7 @@ fun FileManagerScreen( var showFavorites by remember { mutableStateOf(false) } var favTick by remember { mutableIntStateOf(0) } - var listingJob by remember { mutableStateOf(null) } + var listingJob by remember { mutableStateOf(null) } fun loadDirectory(dir: File, resetScroll: Boolean = true) { currentDir = dir @@ -229,19 +234,67 @@ fun FileManagerScreen( LaunchedEffect(Unit) { loadDirectory(rootDir) } - BackHandler(enabled = showFavorites || currentDir != currentRoot) { - if (showFavorites) { - showFavorites = false - return@BackHandler + val canGoUp = currentDir != currentRoot && currentDir.parentFile?.exists() == true + val operationActive = isOperationRunning || operationJob?.isCompleted == false || cancelledJob?.isCompleted == false + + fun navigate(action: FileManagerNavigation) { + when (action) { + FileManagerNavigation.EXIT -> onExit?.invoke() + FileManagerNavigation.RETURN_TO_START -> returnToStart() + FileManagerNavigation.BACK -> { + if (showFavorites) showFavorites = false + else if (canGoUp) currentDir.parentFile?.let { loadDirectory(it) } + else onExit?.invoke() + } } - val parent = currentDir.parentFile - if (parent != null && parent.exists()) loadDirectory(parent) } - val canGoUp = currentDir != currentRoot && currentDir.parentFile?.exists() == true - fun goUp() { - val parent = currentDir.parentFile - if (currentDir != currentRoot && parent != null && parent.exists()) loadDirectory(parent) + fun requestNavigation(action: FileManagerNavigation) { + if (cancellingNavigation) return + if (operationActive) pendingNavigation = action else navigate(action) + } + + BackHandler(enabled = operationActive || cancellingNavigation || showFavorites || currentDir != currentRoot) { + requestNavigation(FileManagerNavigation.BACK) + } + + if (pendingNavigation != null) { + OutlinedAlertDialog( + onDismissRequest = { pendingNavigation = null }, + title = { Text(stringResource(R.string.fm_cancel_operation_title)) }, + text = { Text(stringResource(R.string.fm_cancel_operation_confirmation)) }, + confirmButton = { + TextButton(onClick = { + val action = pendingNavigation ?: return@TextButton + pendingNavigation = null + val job = operationJob?.takeUnless(Job::isCompleted) + ?: cancelledJob?.takeUnless(Job::isCompleted) + if (job == null) { + navigate(action) + } else { + cancellingNavigation = true + cancelledJob = job + job.cancel() + scope.launch { + job.join() + if (operationJob === job) operationJob = null + if (cancelledJob === job) cancelledJob = null + pendingConflict = null + isOperationRunning = false + operationDeterminate = false + if (action != FileManagerNavigation.EXIT) loadDirectory(currentDir, resetScroll = false) + cancellingNavigation = false + navigate(action) + } + } + }) { Text(stringResource(R.string.fm_accept)) } + }, + dismissButton = { + TextButton(onClick = { pendingNavigation = null }) { + Text(stringResource(R.string.fm_decline)) + } + }, + ) } fun uniqueDestination(dir: File, name: String): File { @@ -259,11 +312,13 @@ fun FileManagerScreen( } fun performDelete(file: File) { - scope.launch { + if (operationActive) return + operationJob = scope.launch { isOperationRunning = true operationLabel = context.getString(R.string.fm_op_deleting) val ok = withContext(Dispatchers.IO) { FileOps.delete(file) } isOperationRunning = false + operationJob = null loadDirectory(currentDir, resetScroll = false) if (!ok) Toast.makeText(context, R.string.fm_delete_failed, Toast.LENGTH_SHORT).show() } @@ -356,32 +411,36 @@ fun FileManagerScreen( } fun performRename(file: File, newName: String) { + if (operationActive) return val target = File(file.parentFile, newName) if (target.exists()) { Toast.makeText(context, context.getString(R.string.fm_already_exists, newName), Toast.LENGTH_SHORT).show() return } - scope.launch { + operationJob = scope.launch { isOperationRunning = true operationLabel = context.getString(R.string.fm_op_renaming) val ok = withContext(Dispatchers.IO) { file.renameTo(target) } isOperationRunning = false + operationJob = null loadDirectory(currentDir, resetScroll = false) if (!ok) Toast.makeText(context, R.string.fm_rename_failed, Toast.LENGTH_SHORT).show() } } fun createFolder(parent: File, name: String) { + if (operationActive) return val target = File(parent, name) if (target.exists()) { Toast.makeText(context, context.getString(R.string.fm_already_exists, name), Toast.LENGTH_SHORT).show() return } - scope.launch { + operationJob = scope.launch { isOperationRunning = true operationLabel = context.getString(R.string.fm_op_creating_folder) val ok = withContext(Dispatchers.IO) { target.mkdirs() } isOperationRunning = false + operationJob = null loadDirectory(currentDir, resetScroll = false) if (!ok) Toast.makeText(context, R.string.fm_create_folder_failed, Toast.LENGTH_SHORT).show() } @@ -598,7 +657,7 @@ fun FileManagerScreen( .background(MaterialTheme.colorScheme.surfaceContainer) .padding(horizontal = 8.dp, vertical = 6.dp), ) { - IconButton(onClick = { goUp() }, enabled = canGoUp) { + IconButton(onClick = { requestNavigation(FileManagerNavigation.BACK) }, enabled = canGoUp && !cancellingNavigation) { Icon(Icons.Filled.ArrowBack, stringResource(R.string.fm_back), tint = MaterialTheme.colorScheme.primary) } @@ -689,7 +748,7 @@ fun FileManagerScreen( } if (launchDir != null) { - IconButton(onClick = { returnToStart() }) { + IconButton(onClick = { requestNavigation(FileManagerNavigation.RETURN_TO_START) }, enabled = !cancellingNavigation) { Icon(Icons.AutoMirrored.Filled.KeyboardReturn, stringResource(R.string.fm_return_to_start), tint = MaterialTheme.colorScheme.primary) } if (LocalConfiguration.current.screenWidthDp >= 700) Spacer(Modifier.width(56.dp)) @@ -771,7 +830,7 @@ fun FileManagerScreen( } } if (!pickMode && onExit != null) { - IconButton(onClick = onExit) { + IconButton(onClick = { requestNavigation(FileManagerNavigation.EXIT) }, enabled = !cancellingNavigation) { Icon(Icons.AutoMirrored.Filled.ExitToApp, stringResource(R.string.fm_exit), tint = MaterialTheme.colorScheme.onSurfaceVariant) } } @@ -1014,7 +1073,7 @@ fun FileManagerScreen( contentPadding = PaddingValues(8.dp), ) { if (canGoUp) item(key = "..", span = { GridItemSpan(maxLineSpan) }) { - ParentFolderRow(compact = compactRows, onTap = { goUp() }) + ParentFolderRow(compact = compactRows, onTap = { requestNavigation(FileManagerNavigation.BACK) }) } items(shownEntries, key = { it.absolutePath }) { file -> val isFav = remember(file.absolutePath, favTick) { @@ -1080,7 +1139,7 @@ fun FileManagerScreen( } else LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) { if (canGoUp) item(key = "..") { - ParentFolderRow(compact = compactRows, onTap = { goUp() }) + ParentFolderRow(compact = compactRows, onTap = { requestNavigation(FileManagerNavigation.BACK) }) } if (entries.isEmpty()) { item { diff --git a/app/src/main/java/com/droiddeck/launcher/files/FilePickerActivity.kt b/app/src/main/java/com/droiddeck/launcher/files/FilePickerActivity.kt index 6fd89fa99..498ec504e 100644 --- a/app/src/main/java/com/droiddeck/launcher/files/FilePickerActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/files/FilePickerActivity.kt @@ -50,6 +50,7 @@ class FilePickerActivity : ComponentActivity() { setResult(Activity.RESULT_OK, Intent().putExtra(EXTRA_SELECTED_FILE, file.absolutePath)) finish() }, + onExit = { finish() }, ) } } diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 27109fd06..56a80316f 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -1579,6 +1579,13 @@ تخطّي اختيار هذا المجلد رجوع + خروج + العودة إلى المجلد الأول + لم يعد المجلد الأول متاحًا + إلغاء العملية؟ + سيتم إلغاء العملية. هل أنت متأكد؟ + موافق + رفض تم تركيب %1$s لكن تتعذّر قراءته حاليًا عرض القائمة عرض الشبكة diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index 8988e18d1..40b9c5239 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -1572,6 +1572,13 @@ Omitir Seleccionar esta carpeta Atrás + Salir + Volver a la carpeta inicial + La carpeta inicial ya no está disponible + ¿Cancelar la operación? + La operación se cancelará. ¿Estás seguro? + Aceptar + Rechazar %1$s está montado, pero ahora mismo no se puede leer Vista de lista Vista de cuadrícula diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index d48d3a0ca..cf4a7b516 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -1503,6 +1503,13 @@ Пропускане Избор на тази папка Назад + Изход + Връщане в началната папка + Началната папка вече не е достъпна + Отмяна на операцията? + Операцията ще бъде отменена. Сигурни ли сте? + Приемам + Отказвам %1$s е монтирано, но в момента не може да се прочете Изглед като списък Изглед като мрежа diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index e3b3bc3c0..da649de32 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -1541,6 +1541,13 @@ Přeskočit Vybrat tuto složku Zpět + Ukončit + Zpět do výchozí složky + Výchozí složka již není dostupná + Zrušit operaci? + Operace bude zrušena. Opravdu chcete pokračovat? + Přijmout + Odmítnout %1$s je připojeno, ale momentálně není čitelné Zobrazení seznamu Zobrazení mřížky diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 85062308d..5f50002ec 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -1503,6 +1503,13 @@ Spring over Vælg denne mappe Tilbage + Afslut + Tilbage til startmappen + Startmappen er ikke længere tilgængelig + Annuller handlingen? + Handlingen bliver annulleret. Er du sikker? + Accepter + Afvis %1$s er monteret, men kan ikke læses lige nu Listevisning Gittervisning diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 18199e20e..284ff28d1 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1492,6 +1492,13 @@ Überspringen Diesen Ordner auswählen Zurück + Beenden + Zurück zum Startordner + Startordner ist nicht mehr verfügbar + Vorgang abbrechen? + Der Vorgang wird abgebrochen. Bist du sicher? + Akzeptieren + Ablehnen %1$s ist eingebunden, aber derzeit nicht lesbar Listenansicht Rasteransicht diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 0ff07c465..683495539 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -1503,6 +1503,13 @@ Παράλειψη Επιλογή αυτού του φακέλου Πίσω + Έξοδος + Επιστροφή στον αρχικό φάκελο + Ο αρχικός φάκελος δεν είναι πλέον διαθέσιμος + Ακύρωση ενέργειας; + Η ενέργεια θα ακυρωθεί. Είστε σίγουροι; + Αποδοχή + Απόρριψη Το %1$s είναι προσαρτημένο αλλά δεν είναι αναγνώσιμο αυτή τη στιγμή Προβολή λίστας Προβολή πλέγματος diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 0373f7ab3..26e175bd9 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1519,6 +1519,13 @@ Omitir Seleccionar esta carpeta Atrás + Salir + Volver a la carpeta inicial + La carpeta inicial ya no está disponible + ¿Cancelar la operación? + La operación se cancelará. ¿Estás seguro? + Aceptar + Rechazar %1$s está montado, pero ahora mismo no se puede leer Vista de lista Vista de cuadrícula diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index 2a1f62a03..fa670786d 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -1503,6 +1503,13 @@ Ohita Valitse tämä kansio Takaisin + Poistu + Palaa aloituskansioon + Aloituskansio ei ole enää käytettävissä + Peruutetaanko toiminto? + Toiminto peruutetaan. Oletko varma? + Hyväksy + Hylkää %1$s on liitetty, mutta ei ole luettavissa juuri nyt Luettelonäkymä Ruudukkonäkymä diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index d151596fb..dbc58cad6 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -1519,6 +1519,13 @@ Ignorer Sélectionner ce dossier Retour + Quitter + Revenir au dossier de départ + Le dossier de départ est indisponible + Annuler l’opération ? + Cette opération sera annulée. Confirmez-vous ? + Accepter + Refuser %1$s est monté, mais illisible pour le moment Vue en liste Vue en grille diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index af68d4524..7a93b2ce1 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -1503,6 +1503,13 @@ छोड़ें यह फ़ोल्डर चुनें बैक + बाहर निकलें + शुरुआती फ़ोल्डर पर लौटें + शुरुआती फ़ोल्डर अब उपलब्ध नहीं है + कार्रवाई रद्द करें? + कार्रवाई रद्द कर दी जाएगी। क्या आप सुनिश्चित हैं? + स्वीकार करें + अस्वीकार करें %1$s माउंट है लेकिन अभी पढ़ा नहीं जा सकता लिस्ट व्यू ग्रिड व्यू diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 67f33c54d..81d46e450 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -1503,6 +1503,13 @@ Kihagyás Ennek a mappának a kiválasztása Vissza + Kilépés + Vissza a kezdőmappába + A kezdőmappa már nem érhető el + Művelet megszakítása? + A művelet megszakad. Biztos benne? + Elfogadom + Elutasítom A %1$s csatolva van, de jelenleg nem olvasható Listanézet Rácsnézet diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 6a0371282..58964ebdc 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -1484,6 +1484,13 @@ Lewati Pilih folder ini Kembali + Keluar + Kembali ke folder awal + Folder awal tidak lagi tersedia + Batalkan operasi? + Operasi akan dibatalkan. Anda yakin? + Terima + Tolak %1$s terpasang tetapi tidak dapat dibaca saat ini Tampilan daftar Tampilan kisi diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 6cf24b19d..8e83dad4f 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1511,6 +1511,13 @@ Salta Seleziona questa cartella Indietro + Esci + Torna alla cartella iniziale + La cartella iniziale non è più disponibile + Annullare operazione? + Questa operazione verrà annullata. Confermi? + Accetta + Rifiuta %1$s: montata ma al momento non leggibile Visualizzazione elenco Visualizzazione griglia diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index a1f640853..e5d501ed2 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -1484,6 +1484,13 @@ スキップ このフォルダを選択 戻る + 終了 + 開始フォルダーに戻る + 開始フォルダーは利用できなくなりました + 操作をキャンセルしますか? + 操作がキャンセルされます。よろしいですか? + 承諾 + 拒否 %1$sはマウントされていますが、現在読み取れません リスト表示 グリッド表示 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index ffd0d5a7a..1fbf10b45 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -1484,6 +1484,13 @@ 건너뛰기 이 폴더 선택 뒤로 + 종료 + 시작 폴더로 돌아가기 + 시작 폴더를 더 이상 사용할 수 없습니다 + 작업을 취소할까요? + 작업이 취소됩니다. 계속하시겠습니까? + 수락 + 거절 %1$s이(가) 마운트되었지만 지금은 읽을 수 없습니다 목록 보기 격자 보기 diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index 42d9888b7..7ba18fb32 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -1484,6 +1484,13 @@ Langkau Pilih folder ini Kembali + Keluar + Kembali ke folder asal + Folder asal tidak lagi tersedia + Batalkan operasi? + Operasi akan dibatalkan. Adakah anda pasti? + Terima + Tolak %1$s dilekapkan tetapi tidak dapat dibaca sekarang Paparan senarai Paparan grid diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml index 133d05378..40d10cdb7 100644 --- a/app/src/main/res/values-nb/strings.xml +++ b/app/src/main/res/values-nb/strings.xml @@ -1503,6 +1503,13 @@ Hopp over Velg denne mappen Tilbake + Avslutt + Tilbake til startmappen + Startmappen er ikke lenger tilgjengelig + Avbryt operasjonen? + Operasjonen blir avbrutt. Er du sikker? + Godta + Avslå %1$s er montert, men ikke lesbar akkurat nå Listevisning Rutenettvisning diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index f37c87582..7486a267f 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -1503,6 +1503,13 @@ Overslaan Deze map selecteren Terug + Afsluiten + Terug naar beginmap + De beginmap is niet meer beschikbaar + Bewerking annuleren? + De bewerking wordt geannuleerd. Weet je het zeker? + Accepteren + Weigeren %1$s is gekoppeld, maar nu niet leesbaar Lijstweergave Rasterweergave diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index de29734d2..41a611bf6 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -1541,6 +1541,13 @@ Pomiń Wybierz ten folder Wstecz + Wyjdź + Wróć do folderu początkowego + Folder początkowy nie jest już dostępny + Anulować operację? + Operacja zostanie anulowana. Czy na pewno? + Akceptuj + Odrzuć %1$s: zamontowano, ale obecnie nie można odczytać Widok listy Widok siatki diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index edffdf14a..24a190b2a 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -1511,6 +1511,13 @@ Ignorar Selecionar esta pasta Voltar + Sair + Voltar à pasta inicial + A pasta inicial já não está disponível + Cancelar operação? + A operação será cancelada. Tem a certeza? + Aceitar + Recusar %1$s está montado, mas não é possível lê-lo de momento Vista de lista Vista de grelha diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index a4d96ea94..dc6c63b2f 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -1511,6 +1511,13 @@ Pular Selecionar esta pasta Voltar + Sair + Voltar à pasta inicial + A pasta inicial não está mais disponível + Cancelar operação? + A operação será cancelada. Tem certeza? + Aceitar + Recusar %1$s está montado, mas não pode ser lido agora Visualização em lista Visualização em grade diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index d97af4aed..7ef6b8d11 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -1522,6 +1522,13 @@ Omite Selectează acest folder Înapoi + Ieșire + Înapoi la dosarul inițial + Dosarul inițial nu mai este disponibil + Anulați operația? + Operația va fi anulată. Sunteți sigur? + Acceptă + Refuză %1$s este montat, dar nu poate fi citit acum Vizualizare listă Vizualizare grilă diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 1388738db..37cf74d05 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1538,6 +1538,13 @@ Пропустить Выбрать эту папку Назад + Выход + Вернуться в начальную папку + Начальная папка больше недоступна + Отменить операцию? + Операция будет отменена. Вы уверены? + Принять + Отклонить %1$s: подключено, но сейчас недоступно для чтения Список Сетка diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index caf787c3a..480d8c96a 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -1503,6 +1503,13 @@ Hoppa över Välj den här mappen Tillbaka + Avsluta + Tillbaka till startmappen + Startmappen är inte längre tillgänglig + Avbryt åtgärden? + Åtgärden avbryts. Är du säker? + Acceptera + Avböj %1$s är monterad men inte läsbar just nu Listvy Rutnätsvy diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index 1107a6777..a7b8a4a64 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -1484,6 +1484,13 @@ ข้าม เลือกโฟลเดอร์นี้ กลับ + ออก + กลับไปยังโฟลเดอร์เริ่มต้น + โฟลเดอร์เริ่มต้นไม่พร้อมใช้งานแล้ว + ยกเลิกการดำเนินการ? + การดำเนินการจะถูกยกเลิก คุณแน่ใจหรือไม่? + ยอมรับ + ปฏิเสธ %1$s ติดตั้งอยู่แต่อ่านไม่ได้ในขณะนี้ มุมมองรายการ มุมมองตาราง diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 05cd0192b..30196e875 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -1492,6 +1492,13 @@ Atla Bu klasörü seç Geri + Çıkış + Başlangıç klasörüne dön + Başlangıç klasörü artık kullanılamıyor + İşlem iptal edilsin mi? + İşlem iptal edilecek. Emin misiniz? + Kabul et + Reddet %1$s bağlı ancak şu anda okunamıyor Liste görünümü Izgara görünümü diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index cf16a0938..2c40b53fa 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -1541,6 +1541,13 @@ Пропустити Вибрати цю папку Назад + Вийти + Повернутися до початкової папки + Початкова папка більше недоступна + Скасувати операцію? + Операцію буде скасовано. Ви впевнені? + Прийняти + Відхилити %1$s: підключено, але читання зараз недоступне Список Сітка diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 785e8d57f..64139f47c 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -1484,6 +1484,13 @@ Bỏ qua Chọn thư mục này Quay lại + Thoát + Quay lại thư mục ban đầu + Thư mục ban đầu không còn khả dụng + Hủy thao tác? + Thao tác sẽ bị hủy. Bạn có chắc không? + Chấp nhận + Từ chối %1$s đã được gắn nhưng hiện không đọc được Xem dạng danh sách Xem dạng lưới diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 174e67158..cfbdec72b 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -1560,6 +1560,13 @@ 跳过 选择此文件夹 返回 + 退出 + 返回初始文件夹 + 初始文件夹已不可用 + 取消操作? + 操作将被取消。确定吗? + 接受 + 拒绝 %1$s 已挂载,但当前无法读取 列表视图 网格视图 diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index 97d53634e..60adfbbab 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -1484,6 +1484,13 @@ 略過 選擇此資料夾 返回 + 離開 + 返回初始資料夾 + 初始資料夾已無法使用 + 取消操作? + 操作將會取消。確定嗎? + 接受 + 拒絕 %1$s 已掛載,但目前無法讀取 清單檢視 格狀檢視 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 2d09faba0..3f59e1d00 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -1484,6 +1484,13 @@ 略過 選擇此資料夾 返回 + 離開 + 返回起始資料夾 + 起始資料夾已無法使用 + 取消操作? + 操作將會取消。確定嗎? + 接受 + 拒絕 %1$s 已掛載,但目前無法讀取 清單檢視 格狀檢視 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e64eb7e7d..1c86ac600 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1546,6 +1546,10 @@ Exit Return to start Starting folder is no longer available + Cancel operation? + The operation will be cancelled. Are you sure? + Accept + Decline %1$s is mounted but not readable right now List view Grid view From 0247831c7e579716f703f9f392aecdf73a7473c0 Mon Sep 17 00:00:00 2001 From: K11MCH1 <81425424+K11MCH1@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:46:59 +0900 Subject: [PATCH 03/59] Make File Manager cancellation stop active file I/O Description: - Pass the active coroutine job into copy, move, and delete operations so synchronous file I/O observes cancellation while it is running. - Check for cancellation between copy chunks and recursive delete entries. Remove a newly created partial copy, and stage overwrites so cancelling a copy or move preserves the previous destination and move source. - Add focused tests for cancelling a copy, move fallback, and recursive delete. Why it was needed: - Accept cancelled the Compose coroutine, but FileOps continued its synchronous I/O work. The confirmation could therefore wait for a large copy or delete to finish instead of stopping it. - Copy and move must avoid replacing an existing destination with partial data when cancellation occurs. Copy and move safeguards adapted from upstream commit a74ff3d6 (#1029). Added and adjusted: - Added cancellation checks between file transfer chunks and recursive delete entries, including single-file and batch operations. - Adjusted FileManagerScreen to pass the active job to FileOps and check it during copy progress. Accept now waits for the file operation to stop before Exit, Return, or Back proceeds. - Adjusted overwrite handling to use a temporary file so cancellation preserves the previous destination; an incomplete new destination is removed and a move source remains intact. - Added tests for cancelling a new copy, a move fallback with an existing destination, and a recursive delete. The focused debug unit tests passed. Localization already included on this branch: - The earlier File Manager update added translations for Exit, Return to start, and the unavailable starting-folder message. - It also translated the cancel-operation title and warning, plus the Accept and Decline buttons, across all 32 supported locale resource sets alongside the English defaults. - These strings let localized users understand the navigation controls and the consequences of cancelling an in-progress operation. They were added in the preceding commit 7928effd; this commit makes Accept actually stop the file I/O. --- .../launcher/files/FileManagerScreen.kt | 13 ++- .../com/droiddeck/launcher/files/FileOps.kt | 79 ++++++++++++++++--- .../launcher/files/FileOpsCancellationTest.kt | 55 +++++++++++++ 3 files changed, 130 insertions(+), 17 deletions(-) create mode 100644 app/src/test/java/com/droiddeck/launcher/files/FileOpsCancellationTest.kt diff --git a/app/src/main/java/com/droiddeck/launcher/files/FileManagerScreen.kt b/app/src/main/java/com/droiddeck/launcher/files/FileManagerScreen.kt index 2ce9a1c47..20bc975d6 100644 --- a/app/src/main/java/com/droiddeck/launcher/files/FileManagerScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/files/FileManagerScreen.kt @@ -93,6 +93,7 @@ import androidx.compose.ui.unit.sp import com.droiddeck.launcher.session.SessionPrefs import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.File @@ -316,7 +317,8 @@ fun FileManagerScreen( operationJob = scope.launch { isOperationRunning = true operationLabel = context.getString(R.string.fm_op_deleting) - val ok = withContext(Dispatchers.IO) { FileOps.delete(file) } + val job = coroutineContext[Job] + val ok = withContext(Dispatchers.IO) { FileOps.delete(file) { job?.isActive == false } } isOperationRunning = false operationJob = null loadDirectory(currentDir, resetScroll = false) @@ -342,6 +344,7 @@ fun FileManagerScreen( operationDeterminate = true operationLabel = context.getString(if (cut) R.string.fm_op_moving else R.string.fm_op_copying) isOperationRunning = true + val job = coroutineContext[Job] var applyToAll: ConflictChoice? = null var failed = 0 @@ -377,6 +380,7 @@ fun FileManagerScreen( } var lastPct = -1 val onProgress = FileOps.ProgressCallback { copied, total -> + job?.ensureActive() val pct = if (total > 0) ((copied * 100) / total).toInt() else 100 if (pct != lastPct) { lastPct = pct @@ -385,8 +389,8 @@ fun FileManagerScreen( } val target = dst val ok = withContext(Dispatchers.IO) { - if (cut) FileOps.moveWithProgress(src, target, onProgress) - else FileOps.copyWithProgress(src, target, onProgress) + if (cut) FileOps.moveWithProgress(src, target, onProgress) { job?.isActive == false } + else FileOps.copyWithProgress(src, target, onProgress) { job?.isActive == false } } if (ok) done++ else failed++ } @@ -561,10 +565,11 @@ fun FileManagerScreen( selectedPaths = emptySet() operationJob = scope.launch { isOperationRunning = true + val job = coroutineContext[Job] var failed = 0 victims.forEachIndexed { i, f -> operationLabel = context.getString(R.string.fm_op_deleting_batch, i + 1, victims.size, f.name) - if (!withContext(Dispatchers.IO) { FileOps.delete(f) }) failed++ + if (!withContext(Dispatchers.IO) { FileOps.delete(f) { job?.isActive == false } }) failed++ } isOperationRunning = false operationJob = null diff --git a/app/src/main/java/com/droiddeck/launcher/files/FileOps.kt b/app/src/main/java/com/droiddeck/launcher/files/FileOps.kt index 04b83abaa..6105411cf 100644 --- a/app/src/main/java/com/droiddeck/launcher/files/FileOps.kt +++ b/app/src/main/java/com/droiddeck/launcher/files/FileOps.kt @@ -9,6 +9,7 @@ import java.io.FileOutputStream import java.io.IOException import java.nio.file.Files import java.util.Locale +import java.util.concurrent.CancellationException object FileOps { private const val TAG = "FileOps" @@ -24,18 +25,20 @@ object FileOps { fun isSymlink(file: File): Boolean = try { Files.isSymbolicLink(file.toPath()) } catch (e: Exception) { false } - fun delete(target: File?): Boolean { + fun delete(target: File?, isCancelled: () -> Boolean = { false }): Boolean { + if (isCancelled()) throw CancellationException("Delete cancelled: $target") if (target == null) return false if (target.isDirectory && !isSymlink(target)) { - if (!clear(target)) return false + if (!clear(target, isCancelled)) return false } + if (isCancelled()) throw CancellationException("Delete cancelled: $target") return target.delete() } - fun clear(dir: File): Boolean { + fun clear(dir: File, isCancelled: () -> Boolean = { false }): Boolean { val children = dir.listFiles() ?: return true var ok = true - for (child in children) if (!delete(child)) ok = false + for (child in children) if (!delete(child, isCancelled)) ok = false return ok } @@ -45,22 +48,25 @@ object FileOps { return f.length() } - fun moveWithProgress(src: File, dst: File, progress: ProgressCallback?): Boolean { + fun moveWithProgress(src: File, dst: File, progress: ProgressCallback?, isCancelled: () -> Boolean = { false }): Boolean { if (sameFile(src, dst)) return true + if (isCancelled()) return false if (!dst.exists() && src.renameTo(dst)) { progress?.onProgress(1, 1) return true } - if (!copyWithProgress(src, dst, progress)) return false + if (!copyWithProgress(src, dst, progress, isCancelled)) return false + if (isCancelled()) return false return delete(src) } - fun copyWithProgress(src: File, dst: File, progress: ProgressCallback?): Boolean { + fun copyWithProgress(src: File, dst: File, progress: ProgressCallback?, isCancelled: () -> Boolean = { false }): Boolean { val total = maxOf(totalSize(src), 1L) - return copyWithProgress(src, dst, longArrayOf(0), total, progress) + return copyWithProgress(src, dst, longArrayOf(0), total, progress, isCancelled) } - private fun copyWithProgress(src: File, dst: File, done: LongArray, total: Long, progress: ProgressCallback?): Boolean { + private fun copyWithProgress(src: File, dst: File, done: LongArray, total: Long, progress: ProgressCallback?, isCancelled: () -> Boolean): Boolean { + if (isCancelled()) throw CancellationException("Copy cancelled: $src") if (isSymlink(src)) return true if (sameFile(src, dst)) return true if (src.isDirectory && isWithin(dst, src)) { @@ -71,7 +77,7 @@ object FileOps { if (!dst.exists() && !dst.mkdirs()) return false var allOk = true src.list()?.forEach { name -> - if (!copyWithProgress(File(src, name), File(dst, name), done, total, progress)) { + if (!copyWithProgress(File(src, name), File(dst, name), done, total, progress, isCancelled)) { Log.e(TAG, "Failed to copy: ${File(src, name)}") allOk = false } @@ -81,13 +87,42 @@ object FileOps { val parent = dst.parentFile if (!src.exists() || (parent != null && !parent.exists() && !parent.mkdirs())) return false val chunk = 4L * 1024 * 1024 + var staging: File? = null + var created = false + var output: java.nio.channels.FileChannel? = null try { + output = try { + java.nio.channels.FileChannel.open(dst.toPath(), java.nio.file.StandardOpenOption.WRITE, java.nio.file.StandardOpenOption.CREATE_NEW) + .also { created = true } + } catch (e: java.nio.file.FileAlreadyExistsException) { + null + } + val target = if (!created && isSymlink(dst)) dst.canonicalFile else dst + var permissions: Set? = null + if (output == null) { + if (isSymlink(target) || target.parentFile?.isDirectory != true) { + throw IOException("Destination symlink target is not a writable directory entry: $dst") + } + if (target.isDirectory) throw IOException("Destination is a directory: $target") + permissions = try { + Files.getPosixFilePermissions(target.toPath()) + } catch (e: java.nio.file.NoSuchFileException) { + null + } catch (e: UnsupportedOperationException) { + null + } + if (target.exists() && !target.canWrite()) throw IOException("Destination is not writable: $target") + val directory = Files.createTempDirectory(checkNotNull(target.absoluteFile.parentFile).toPath(), ".droiddeck-copy-") + staging = directory.toFile() + output = FileOutputStream(File(staging, "file")).channel + } FileInputStream(src).channel.use { input -> - FileOutputStream(dst).channel.use { output -> + checkNotNull(output).use { channel -> val size = input.size() var position = 0L while (position < size) { - val transferred = input.transferTo(position, minOf(chunk, size - position), output) + if (isCancelled()) throw CancellationException("Copy cancelled: $src") + val transferred = input.transferTo(position, minOf(chunk, size - position), channel) if (transferred <= 0) break position += transferred done[0] += transferred @@ -96,11 +131,29 @@ object FileOps { if (position < size) throw IOException("Incomplete copy: $position/$size bytes") } } + if (isCancelled()) throw CancellationException("Copy cancelled: $src") + staging?.let { directory -> + val copied = File(directory, "file").toPath() + if (permissions != null && Files.getPosixFilePermissions(copied) != permissions) { + Files.setPosixFilePermissions(copied, permissions) + } + if (isCancelled()) throw CancellationException("Copy cancelled: $src") + Files.move(copied, target.toPath(), java.nio.file.StandardCopyOption.ATOMIC_MOVE) + } return true + } catch (e: CancellationException) { + if (created) dst.delete() + throw e } catch (e: IOException) { Log.e(TAG, "Failed to copy file: $src to $dst", e) - dst.delete() + if (created) dst.delete() return false + } finally { + output?.close() + staging?.let { directory -> + File(directory, "file").delete() + directory.delete() + } } } diff --git a/app/src/test/java/com/droiddeck/launcher/files/FileOpsCancellationTest.kt b/app/src/test/java/com/droiddeck/launcher/files/FileOpsCancellationTest.kt new file mode 100644 index 000000000..30c50558a --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/files/FileOpsCancellationTest.kt @@ -0,0 +1,55 @@ +package com.droiddeck.launcher.files + +import java.io.File +import java.util.concurrent.CancellationException +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class FileOpsCancellationTest { + @get:Rule val tmp = TemporaryFolder() + + @Test fun cancellingCopyRemovesAnIncompleteNewFile() { + val source = tmp.newFile("source").apply { writeBytes(ByteArray(5 * 1024 * 1024)) } + val destination = File(tmp.root, "destination") + + assertThrows(CancellationException::class.java) { + FileOps.copyWithProgress(source, destination, { _, _ -> throw CancellationException("cancel") }) + } + + assertTrue(source.exists()) + assertFalse(destination.exists()) + } + + @Test fun cancellingMoveFallbackPreservesTheSourceAndExistingDestination() { + val source = tmp.newFile("source").apply { writeText("replacement") } + val destination = tmp.newFile("destination").apply { writeText("original") } + + assertThrows(CancellationException::class.java) { + FileOps.moveWithProgress(source, destination, { _, _ -> throw CancellationException("cancel") }) + } + + assertEquals("replacement", source.readText()) + assertEquals("original", destination.readText()) + } + + @Test fun cancellingRecursiveDeleteStopsBeforeTheNextFile() { + val directory = tmp.newFolder("directory") + repeat(3) { File(directory, "file-$it").writeText("content") } + var checks = 0 + + assertThrows(CancellationException::class.java) { + FileOps.delete(directory) { ++checks > 3 } + } + + assertTrue(directory.exists()) + assertEquals(2, directory.listFiles()?.size) + } +} From cb153ece872485a5f46ee630af1a13e571b8d1fd Mon Sep 17 00:00:00 2001 From: K11MCH1 <81425424+K11MCH1@users.noreply.github.com> Date: Thu, 22 Oct 2026 21:29:18 +0900 Subject: [PATCH 04/59] Guard File Manager jobs and clean up cancelled copies Description: - Reject bulk delete while another operation is active. Check current operation state at action time, and clear operationJob on completion only when the finishing coroutine still owns it. - Retain the requested Return directory even when it is unavailable, so Return can display the existing unavailable-directory message while browsing starts from the valid fallback. - Observe cancellation during the recursive size scan before copying. Remove destination directories created by a cancelled copy, while preserving existing destination directories and ordinary copy-failure behavior. - After a move's copy commits, finish deleting the source instead of reporting cancellation with both copies left behind. Preserve cancellation before copying and the existing copy-failure path. - Log permission-restoration failures in staged copies and continue publishing the copied file. - Exercise cancellation signals during transfers and size scans, directory cleanup, preservation of existing directories, and committed move completion without leftover staging entries. Why it was needed: - Overlapping operations could replace the tracked job, and an older completion could clear that reference, allowing navigation before the remaining work stopped. - Cancellation could leave new partial directory trees or wait through the size scan. Late move cancellation could leave the source behind after publishing its replacement. - A missing launch directory suppressed Return entirely, and a permission-restoration error could prevent an otherwise successful overwrite. Validation: - Debug Kotlin compilation passed with skipRust=true. - Focused FileOps cancellation, copy, and link suites: 17 passed, 10 platform-dependent cases skipped on Windows, no failures. - git diff --check passed. --- .../launcher/files/FileManagerScreen.kt | 28 ++++---- .../com/droiddeck/launcher/files/FileOps.kt | 43 ++++++++---- .../launcher/files/FileOpsCancellationTest.kt | 70 ++++++++++++++++++- .../launcher/files/FileOpsCopyTest.kt | 14 ++-- 4 files changed, 119 insertions(+), 36 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/files/FileManagerScreen.kt b/app/src/main/java/com/droiddeck/launcher/files/FileManagerScreen.kt index 72398b63f..be804fd0a 100644 --- a/app/src/main/java/com/droiddeck/launcher/files/FileManagerScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/files/FileManagerScreen.kt @@ -128,7 +128,7 @@ fun FileManagerScreen( initialDir?.takeIf { it.isDirectory } ?: File("/storage/emulated/0") } val launchDir = remember(initialDir, pickMode) { - initialDir?.takeIf { it.isDirectory && !pickMode } + initialDir?.takeIf { !pickMode } } var currentDir by remember { mutableStateOf(rootDir) } val initialRoot = remember(rootDir) { @@ -236,7 +236,8 @@ fun FileManagerScreen( LaunchedEffect(Unit) { loadDirectory(rootDir) } val canGoUp = currentDir != currentRoot && currentDir.parentFile?.exists() == true - val operationActive = isOperationRunning || operationJob?.isCompleted == false || cancelledJob?.isCompleted == false + fun isOperationActive() = isOperationRunning || operationJob?.isCompleted == false || cancelledJob?.isCompleted == false + val operationActive = isOperationActive() fun navigate(action: FileManagerNavigation) { when (action) { @@ -252,7 +253,7 @@ fun FileManagerScreen( fun requestNavigation(action: FileManagerNavigation) { if (cancellingNavigation) return - if (operationActive) pendingNavigation = action else navigate(action) + if (isOperationActive()) pendingNavigation = action else navigate(action) } BackHandler(enabled = operationActive || cancellingNavigation || showFavorites || currentDir != currentRoot) { @@ -313,14 +314,14 @@ fun FileManagerScreen( } fun performDelete(file: File) { - if (operationActive) return + if (isOperationActive()) return operationJob = scope.launch { isOperationRunning = true operationLabel = context.getString(R.string.fm_op_deleting) val job = coroutineContext[Job] val ok = withContext(Dispatchers.IO) { FileOps.delete(file) { job?.isActive == false } } isOperationRunning = false - operationJob = null + if (operationJob === job) operationJob = null loadDirectory(currentDir, resetScroll = false) if (!ok) Toast.makeText(context, R.string.fm_delete_failed, Toast.LENGTH_SHORT).show() } @@ -335,7 +336,7 @@ fun FileManagerScreen( fun performPaste() { val sources = clipboardFiles - if (sources.isEmpty() || isOperationRunning || operationJob?.isActive == true || cancelledJob?.isCompleted == false) return + if (sources.isEmpty() || isOperationActive()) return val dstDir = currentDir val cut = isCutOperation @@ -397,7 +398,7 @@ fun FileManagerScreen( isOperationRunning = false operationDeterminate = false - operationJob = null + if (operationJob === job) operationJob = null clipboardFiles = emptyList() isCutOperation = false selectionMode = false @@ -415,36 +416,38 @@ fun FileManagerScreen( } fun performRename(file: File, newName: String) { - if (operationActive) return + if (isOperationActive()) return val target = File(file.parentFile, newName) if (target.exists()) { Toast.makeText(context, context.getString(R.string.fm_already_exists, newName), Toast.LENGTH_SHORT).show() return } operationJob = scope.launch { + val job = coroutineContext[Job] isOperationRunning = true operationLabel = context.getString(R.string.fm_op_renaming) val ok = withContext(Dispatchers.IO) { file.renameTo(target) } isOperationRunning = false - operationJob = null + if (operationJob === job) operationJob = null loadDirectory(currentDir, resetScroll = false) if (!ok) Toast.makeText(context, R.string.fm_rename_failed, Toast.LENGTH_SHORT).show() } } fun createFolder(parent: File, name: String) { - if (operationActive) return + if (isOperationActive()) return val target = File(parent, name) if (target.exists()) { Toast.makeText(context, context.getString(R.string.fm_already_exists, name), Toast.LENGTH_SHORT).show() return } operationJob = scope.launch { + val job = coroutineContext[Job] isOperationRunning = true operationLabel = context.getString(R.string.fm_op_creating_folder) val ok = withContext(Dispatchers.IO) { target.mkdirs() } isOperationRunning = false - operationJob = null + if (operationJob === job) operationJob = null loadDirectory(currentDir, resetScroll = false) if (!ok) Toast.makeText(context, R.string.fm_create_folder_failed, Toast.LENGTH_SHORT).show() } @@ -560,6 +563,7 @@ fun FileManagerScreen( }, confirmButton = { TextButton(onClick = { + if (isOperationActive()) return@TextButton pendingBulkDelete = emptyList() selectionMode = false selectedPaths = emptySet() @@ -572,7 +576,7 @@ fun FileManagerScreen( if (!withContext(Dispatchers.IO) { FileOps.delete(f) { job?.isActive == false } }) failed++ } isOperationRunning = false - operationJob = null + if (operationJob === job) operationJob = null loadDirectory(currentDir, resetScroll = false) if (failed > 0) { Toast.makeText(context, context.resources.getQuantityString(R.plurals.fm_delete_some_failed, failed, failed), Toast.LENGTH_SHORT).show() diff --git a/app/src/main/java/com/droiddeck/launcher/files/FileOps.kt b/app/src/main/java/com/droiddeck/launcher/files/FileOps.kt index c14bb6edd..05d467ef5 100644 --- a/app/src/main/java/com/droiddeck/launcher/files/FileOps.kt +++ b/app/src/main/java/com/droiddeck/launcher/files/FileOps.kt @@ -42,9 +42,10 @@ object FileOps { return ok } - fun totalSize(f: File?): Long { + fun totalSize(f: File?, isCancelled: () -> Boolean = { false }): Long { + if (isCancelled()) throw CancellationException("Size scan cancelled: $f") if (f == null || !f.exists() || isSymlink(f)) return 0 - if (f.isDirectory) return f.listFiles()?.sumOf { totalSize(it) } ?: 0 + if (f.isDirectory) return f.listFiles()?.sumOf { totalSize(it, isCancelled) } ?: 0 return f.length() } @@ -56,12 +57,12 @@ object FileOps { return true } if (!copyWithProgress(src, dst, progress, isCancelled)) return false - if (isCancelled()) return false - return delete(src, isCancelled) + // Once the destination is committed, finish deleting the source. + return delete(src) } fun copyWithProgress(src: File, dst: File, progress: ProgressCallback?, isCancelled: () -> Boolean = { false }): Boolean { - val total = maxOf(totalSize(src), 1L) + val total = maxOf(totalSize(src, isCancelled), 1L) return copyWithProgress(src, dst, longArrayOf(0), total, progress, isCancelled) } @@ -74,15 +75,23 @@ object FileOps { return false } if (src.isDirectory) { - if (!dst.exists() && !dst.mkdirs()) return false - var allOk = true - src.list()?.forEach { name -> - if (!copyWithProgress(File(src, name), File(dst, name), done, total, progress, isCancelled)) { - Log.e(TAG, "Failed to copy: ${File(src, name)}") - allOk = false + val created = if (dst.exists()) false else { + if (!dst.mkdirs()) return false + true + } + try { + var allOk = true + src.list()?.forEach { name -> + if (!copyWithProgress(File(src, name), File(dst, name), done, total, progress, isCancelled)) { + Log.e(TAG, "Failed to copy: ${File(src, name)}") + allOk = false + } } + return allOk + } catch (e: CancellationException) { + if (created) delete(dst) + throw e } - return allOk } val parent = dst.parentFile if (!src.exists() || (parent != null && !parent.exists() && !parent.mkdirs())) return false @@ -134,8 +143,14 @@ object FileOps { } staging?.let { directory -> val copied = File(directory, "file").toPath() - if (permissions != null && Files.getPosixFilePermissions(copied) != permissions) { - Files.setPosixFilePermissions(copied, permissions) + if (permissions != null) { + try { + if (Files.getPosixFilePermissions(copied) != permissions) { + Files.setPosixFilePermissions(copied, permissions) + } + } catch (e: Exception) { + Log.w(TAG, "Failed to restore destination permissions: $target", e) + } } if (isCancelled()) throw java.util.concurrent.CancellationException("Copy cancelled: $src") Files.move(copied, target.toPath(), java.nio.file.StandardCopyOption.ATOMIC_MOVE) diff --git a/app/src/test/java/com/droiddeck/launcher/files/FileOpsCancellationTest.kt b/app/src/test/java/com/droiddeck/launcher/files/FileOpsCancellationTest.kt index 30c50558a..8ba15591e 100644 --- a/app/src/test/java/com/droiddeck/launcher/files/FileOpsCancellationTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/files/FileOpsCancellationTest.kt @@ -19,9 +19,10 @@ class FileOpsCancellationTest { @Test fun cancellingCopyRemovesAnIncompleteNewFile() { val source = tmp.newFile("source").apply { writeBytes(ByteArray(5 * 1024 * 1024)) } val destination = File(tmp.root, "destination") + var cancelled = false assertThrows(CancellationException::class.java) { - FileOps.copyWithProgress(source, destination, { _, _ -> throw CancellationException("cancel") }) + FileOps.copyWithProgress(source, destination, { _, _ -> cancelled = true }) { cancelled } } assertTrue(source.exists()) @@ -31,15 +32,80 @@ class FileOpsCancellationTest { @Test fun cancellingMoveFallbackPreservesTheSourceAndExistingDestination() { val source = tmp.newFile("source").apply { writeText("replacement") } val destination = tmp.newFile("destination").apply { writeText("original") } + var cancelled = false assertThrows(CancellationException::class.java) { - FileOps.moveWithProgress(source, destination, { _, _ -> throw CancellationException("cancel") }) + FileOps.moveWithProgress(source, destination, { _, _ -> cancelled = true }) { cancelled } } assertEquals("replacement", source.readText()) assertEquals("original", destination.readText()) } + @Test fun aCommittedMoveFinishesRemovingTheSourceDespiteCancellation() { + val source = tmp.newFile("source").apply { writeText("replacement") } + val destination = tmp.newFile("destination").apply { writeText("original") } + val isCancelled = { destination.readText() == "replacement" } + + assertTrue(FileOps.moveWithProgress(source, destination, null, isCancelled)) + + assertTrue(isCancelled()) + assertEquals("replacement", destination.readText()) + assertFalse(source.exists()) + assertEquals(setOf("destination"), tmp.root.list()!!.toSet()) + } + + @Test fun cancellingTheSizeScanStopsBeforeCreatingTheDestination() { + val source = tmp.newFolder("source") + repeat(3) { File(source, "file-$it").writeText("content") } + val destination = File(tmp.root, "destination") + var checks = 0 + + assertThrows(CancellationException::class.java) { + FileOps.copyWithProgress(source, destination, null) { + assertFalse(destination.exists()) + ++checks > 2 + } + } + + assertEquals(3, checks) + assertFalse(destination.exists()) + assertEquals(3, source.listFiles()?.size) + } + + @Test fun cancellingCopyRemovesANewDestinationDirectory() { + val source = tmp.newFolder("source") + val payload = File(source, "nested/payload").apply { + parentFile!!.mkdirs() + writeText("content") + } + val destination = File(tmp.root, "destination") + + assertThrows(CancellationException::class.java) { + FileOps.copyWithProgress(source, destination, { _, _ -> throw CancellationException("cancel") }) + } + + assertEquals("content", payload.readText()) + assertFalse(destination.exists()) + } + + @Test fun cancellingCopyPreservesAnExistingDestinationDirectory() { + val source = tmp.newFolder("source") + File(source, "nested/payload").apply { + parentFile!!.mkdirs() + writeText("replacement") + } + val destination = tmp.newFolder("destination") + val existing = File(destination, "keep").apply { writeText("original") } + + assertThrows(CancellationException::class.java) { + FileOps.copyWithProgress(source, destination, { _, _ -> throw CancellationException("cancel") }) + } + + assertEquals("original", existing.readText()) + assertEquals(setOf("keep"), destination.list()!!.toSet()) + } + @Test fun cancellingRecursiveDeleteStopsBeforeTheNextFile() { val directory = tmp.newFolder("directory") repeat(3) { File(directory, "file-$it").writeText("content") } diff --git a/app/src/test/java/com/droiddeck/launcher/files/FileOpsCopyTest.kt b/app/src/test/java/com/droiddeck/launcher/files/FileOpsCopyTest.kt index ac13ac781..e92ca2aec 100644 --- a/app/src/test/java/com/droiddeck/launcher/files/FileOpsCopyTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/files/FileOpsCopyTest.kt @@ -53,27 +53,25 @@ class FileOpsCopyTest { assertEquals("replacement", src.readText()) } - @Test fun cancellingStopsATreeOfEmptyFiles() { + @Test fun cancellingRemovesANewTreeOfEmptyFiles() { val src = tmp.newFolder("source") for (name in listOf("a", "b", "c")) File(src, name).createNewFile() val dst = File(tmp.root, "dest") - var checks = 0 assertThrows(CancellationException::class.java) { - FileOps.copyWithProgress(src, dst, null) { checks++ > 1 } + FileOps.copyWithProgress(src, dst, null) { dst.list()?.isNotEmpty() == true } } - assertEquals(1, dst.list()!!.size) + assertFalse(dst.exists()) assertEquals(3, src.list()!!.size) } - @Test fun cancellingStopsATreeOfEmptyFolders() { + @Test fun cancellingRemovesANewTreeOfEmptyFolders() { val src = tmp.newFolder("source") for (name in listOf("a", "b", "c")) File(src, name).mkdir() val dst = File(tmp.root, "dest") - var checks = 0 assertThrows(CancellationException::class.java) { - FileOps.copyWithProgress(src, dst, null) { checks++ > 1 } + FileOps.copyWithProgress(src, dst, null) { dst.list()?.isNotEmpty() == true } } - assertEquals(1, dst.list()!!.size) + assertFalse(dst.exists()) } @Test fun aCompletedCopyReplacesTheDestination() { From 3b1dfadcf929303f6f5c3403c117edaaadae6d90 Mon Sep 17 00:00:00 2001 From: xXJsonDeruloXx Date: Thu, 22 Oct 2026 10:29:24 -0400 Subject: [PATCH 05/59] fix: keep deferred back inside selected drive root --- .../launcher/files/FileManagerScreen.kt | 11 ++++++++-- .../files/FileManagerNavigationTest.kt | 20 +++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 app/src/test/java/com/droiddeck/launcher/files/FileManagerNavigationTest.kt diff --git a/app/src/main/java/com/droiddeck/launcher/files/FileManagerScreen.kt b/app/src/main/java/com/droiddeck/launcher/files/FileManagerScreen.kt index be804fd0a..627761c2d 100644 --- a/app/src/main/java/com/droiddeck/launcher/files/FileManagerScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/files/FileManagerScreen.kt @@ -101,6 +101,9 @@ import java.util.Date private enum class FileManagerNavigation { EXIT, RETURN_TO_START, BACK } +internal fun canNavigateUp(currentDir: File, currentRoot: File): Boolean = + currentDir != currentRoot && currentDir.parentFile?.exists() == true + @OptIn(ExperimentalMaterial3Api::class) @Composable fun FileManagerScreen( @@ -192,6 +195,7 @@ fun FileManagerScreen( var listingJob by remember { mutableStateOf(null) } fun loadDirectory(dir: File, resetScroll: Boolean = true) { + if (cancellingNavigation) return currentDir = dir if (pickMode) pickPrefs.edit().putString("lastFilePickerDir", dir.absolutePath).apply() listingJob?.cancel() @@ -216,11 +220,13 @@ fun FileManagerScreen( } fun openDrive(dir: File) { + if (cancellingNavigation) return currentRoot = dir loadDirectory(dir) } fun returnToStart() { + if (cancellingNavigation) return val dir = launchDir ?: return if (!dir.isDirectory) { Toast.makeText(context, R.string.fm_start_unavailable, Toast.LENGTH_SHORT).show() @@ -240,12 +246,13 @@ fun FileManagerScreen( val operationActive = isOperationActive() fun navigate(action: FileManagerNavigation) { + if (cancellingNavigation) return when (action) { FileManagerNavigation.EXIT -> onExit?.invoke() FileManagerNavigation.RETURN_TO_START -> returnToStart() FileManagerNavigation.BACK -> { if (showFavorites) showFavorites = false - else if (canGoUp) currentDir.parentFile?.let { loadDirectory(it) } + else if (canNavigateUp(currentDir, currentRoot)) currentDir.parentFile?.let { loadDirectory(it) } else onExit?.invoke() } } @@ -284,8 +291,8 @@ fun FileManagerScreen( pendingConflict = null isOperationRunning = false operationDeterminate = false - if (action != FileManagerNavigation.EXIT) loadDirectory(currentDir, resetScroll = false) cancellingNavigation = false + if (action != FileManagerNavigation.EXIT) loadDirectory(currentDir, resetScroll = false) navigate(action) } } diff --git a/app/src/test/java/com/droiddeck/launcher/files/FileManagerNavigationTest.kt b/app/src/test/java/com/droiddeck/launcher/files/FileManagerNavigationTest.kt new file mode 100644 index 000000000..cbcf20755 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/files/FileManagerNavigationTest.kt @@ -0,0 +1,20 @@ +package com.droiddeck.launcher.files + +import java.io.File +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class FileManagerNavigationTest { + @get:Rule val tmp = TemporaryFolder() + + @Test fun backCannotNavigateAboveTheCurrentDriveRoot() { + val drive = tmp.newFolder("drive") + val child = File(drive, "folder").apply { mkdir() } + + assertTrue(canNavigateUp(child, drive)) + assertFalse(canNavigateUp(drive, drive)) + } +} From 663da2948c66b9de6ffa53c06e9fb719c593c787 Mon Sep 17 00:00:00 2001 From: The412Banner Date: Thu, 22 Oct 2026 22:12:20 +0000 Subject: [PATCH 06/59] fix(steam): the last change to a game's compatibility tool wins A game's tool can be chosen in DroidDeck (per-game Proton) and in Steam's game properties. When they disagreed, DroidDeck always put its own choice back: the live helper within a second of the change, and again at every session start. Resetting the game's DroidDeck profile made it worse - it saved "use the default" (null), which was then forced onto the game. A DroidDeck choice is now applied once, and the client's later pick stands: a pick of one of DroidDeck's Protons becomes the DroidDeck choice too, any other pick (FEX, a Steam Linux Runtime, clearing the forced tool) ends it. "Use the default" and a profile reset are applied once and then dropped, so the game follows the default like one DroidDeck never touched. What was applied is kept in state.json (games_applied); choices an earlier build already applied count as applied. games.json is rewritten only if the app did not replace it meanwhile. An x86-only Proton picked in Steam is still replaced, since it cannot start on the phone. --- .../usr/local/bin/droiddeck-steam-compat | 13 ++- .../overlay/usr/local/bin/steam-compatibility | 102 +++++++++++++++++- tools/tests/test_steam_compat_live.py | 82 ++++++++++++++ 3 files changed, 187 insertions(+), 10 deletions(-) diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-compat b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-compat index 3972faa9b..e751d1f95 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-compat +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-compat @@ -583,9 +583,8 @@ class Helper: picked = COMPAT["picked_apps"](state) runnable = {t["name"].lower() for t in live} apps = sorted(set(installed) | set(picks) | set(prior_auto) | native_choices, key=COMPAT["app_order"]) - overrides = COMPAT["requested_games"]( - COMPAT["game_requests"](), catalog, [t["name"] for t in live]) - apps = sorted(set(apps) | set(overrides), key=COMPAT["app_order"]) + asked = [app for app, request in COMPAT["game_requests"]().items() if COMPAT["valid_game_request"](app, request)] + apps = sorted(set(apps) | set(asked), key=COMPAT["app_order"]) current = dict(page.compat("many(%s)" % json.dumps(apps)) or {}) refused = set(apps) - set(current) refused_native = native_choices & refused @@ -597,7 +596,6 @@ class Helper: if isinstance(state.get("auto"), dict): state["auto"] = prior_auto native_choices -= refused - overrides = {app: tool for app, tool in overrides.items() if app not in refused} current["0"] = top["selected"] native = COMPAT["native_targets"](self.steam_root, apps) raw_mapping = COMPAT["read_config_mapping"]( @@ -619,6 +617,10 @@ class Helper: current[app] = "" elif not current.get(app): current.pop(app, None) + # The client's pick of a tool the app chose for a game wins once the app's choice was applied. + requests = COMPAT["settled_game_requests"](state, current, catalog) + overrides = COMPAT["requested_games"](requests, catalog, [t["name"] for t in live]) + overrides = {app: tool for app, tool in overrides.items() if app not in refused} changes, auto = COMPAT["plan_mapping"]( current, installed, [t["name"] for t in live], default, COMPAT["followers"](state, current), native, picks, overrides, @@ -630,7 +632,8 @@ class Helper: state.update( auto={app: tool for app, tool in auto.items() if tool is not COMPAT["NATIVE_CHOICE"]}, native_choices=sorted(refused_native | {app for app, tool in auto.items() if tool is COMPAT["NATIVE_CHOICE"]}), - picked=COMPAT["picked_after"](current, changes), tools=catalog, live=True) + picked=COMPAT["picked_after"](current, changes), tools=catalog, live=True, + games_applied=COMPAT["applied_game_requests"](requests, overrides, state)) state.pop("native", None) if waiting: state["pending"] = wanted diff --git a/tools/linuxfs/overlay/usr/local/bin/steam-compatibility b/tools/linuxfs/overlay/usr/local/bin/steam-compatibility index 41a37a9aa..055f33ede 100755 --- a/tools/linuxfs/overlay/usr/local/bin/steam-compatibility +++ b/tools/linuxfs/overlay/usr/local/bin/steam-compatibility @@ -1113,10 +1113,99 @@ def requested_tool(request, catalog): def game_requests(directory=None): - value = read_json(state_path("games.json", directory)) - if value.get("version") != 1 or not isinstance(value.get("games"), dict): - return {} - return value["games"] + return read_game_requests(directory)[0] + + +def read_game_requests(directory=None): + """The app's per-game Proton choices, and the file's text so a rewrite can tell the app wrote it since.""" + try: + with open(state_path("games.json", directory), encoding="utf-8") as f: + text = f.read() + value = json.loads(text) + except (OSError, ValueError): + return {}, None + if not isinstance(value, dict) or value.get("version") != 1 or not isinstance(value.get("games"), dict): + return {}, text + return value["games"], text + + +def write_game_requests(requests, original, directory=None): + """Rewrites games.json unless the app replaced it after it was read; that newer choice wins.""" + path = state_path("games.json", directory) + try: + with open(path, encoding="utf-8") as f: + if f.read() != original: + return False + except OSError: + if original is not None: + return False + return write_json(path, {"version": 1, "games": requests}) + + +def valid_game_request(app, request): + return (isinstance(app, str) and re.fullmatch(r"[1-9][0-9]*", app) is not None and int(app) <= 0xFFFFFFFF and + (request is None or isinstance(request, dict) and isinstance(request.get("dir"), str) and + request["dir"] != "" and isinstance(request.get("valve"), bool))) + + +def settle_game_requests(requests, state, current, catalog, missing_is_cleared=False): + """Whichever side changed a game's tool last wins. A choice the app makes is applied once; after + that the client's own pick stands: a pick of one of our Protons becomes the app's choice too, any + other pick (FEX, a Steam Linux Runtime, clearing the forced tool) ends the app's choice. "Use the + default" (null, also what a profile reset writes) is applied once and then dropped, so the game + follows the default like one the app never touched. Returns the new requests and whether they changed.""" + recorded = state.get("games_applied") + if isinstance(recorded, dict): + applied = dict(recorded) + else: + # Builds before this one kept no record but had already applied every choice they found; a + # first start has applied nothing yet. + applied = dict(requests) if "tools" in state else {} + by_name = {t["name"]: t for t in catalog} + out = dict(requests) + for app, request in requests.items(): + if not valid_game_request(app, request) or app not in applied or applied[app] != request: + continue + if request is None: + del out[app] + continue + wanted = requested_tool(request, catalog) + if wanted is None: + continue + if app in current: + picked = current[app] + elif missing_is_cleared: + picked = "" + else: + continue + if picked == wanted: + continue + tool = by_name.get(picked) + if tool and tool["dir"]: + out[app] = {"valve": tool["valve"], "dir": tool["dir"]} + else: + del out[app] + return out, out != requests + + +def settled_game_requests(state, current, catalog, missing_is_cleared=False, directory=None): + """Reads the app's choices, settles them against the client's picks and stores the result. When the + app rewrote the file meanwhile, its newer choices are settled again; they are never overwritten.""" + for _ in range(2): + requests, text = read_game_requests(directory) + settled, moved = settle_game_requests(requests, state, current, catalog, missing_is_cleared) + if not moved or write_game_requests(settled, text, directory): + return settled + return settled + + +def applied_game_requests(requests, overrides, state): + """What the app asked for that is now in the mapping; a tool not installed yet stays unapplied.""" + recorded = state.get("games_applied") + before = recorded if isinstance(recorded, dict) else {} + return {app: request if overrides.get(app) is not DEFERRED_GAME_TOOL else before.get(app) + for app, request in requests.items() if app in overrides and + (overrides.get(app) is not DEFERRED_GAME_TOOL or app in before)} def requested_games(requests, catalog, runnable=None): @@ -1306,7 +1395,9 @@ def register_default(config_path, apps=(), protect=(), default=None, catalog=Non else: default = state.get("default") if state.get("default") in names else TOOL steam_root = os.path.dirname(os.path.dirname(os.path.abspath(config_path))) - overrides = requested_games(game_requests(directory), catalog) + # With the client stopped, config.vdf is what it last kept: an app missing from it has no forced tool. + requests = settled_game_requests(state, current, catalog, not steam_running(), directory) + overrides = requested_games(requests, catalog) managed = state.get("auto", {}).keys() if isinstance(state.get("auto"), dict) else () native_choices = state.get("native_choices", ()) native = native_targets(steam_root, set(apps) | set(picks) | set(managed) | set(native_choices)) @@ -1325,6 +1416,7 @@ def register_default(config_path, apps=(), protect=(), default=None, catalog=Non state["auto"] = {app: tool for app, tool in auto.items() if tool is not NATIVE_CHOICE} state["native_choices"] = sorted(app for app, tool in auto.items() if tool is NATIVE_CHOICE) state["picked"] = picked_after(current, changes) + state["games_applied"] = applied_game_requests(requests, overrides, state) state.pop("native", None) state["tools"] = catalog state.setdefault("default", default) diff --git a/tools/tests/test_steam_compat_live.py b/tools/tests/test_steam_compat_live.py index a86aab864..5b793c313 100644 --- a/tools/tests/test_steam_compat_live.py +++ b/tools/tests/test_steam_compat_live.py @@ -641,6 +641,88 @@ class ReconcileTest(unittest.TestCase): self.assertEqual(page.mapping["10"], "GE-Proton9-1") self.assertEqual(state["auto"], {"11": TOOL}) + def games(self): + return json.loads((self.state / "games.json").read_text())["games"] + + def write_games(self, games): + (self.state / "games.json").write_text(json.dumps({"version": 1, "games": games})) + + def test_a_steam_pick_after_the_app_choice_is_kept_live(self): + page = FakePage({"0": TOOL, "10": TOOL, "11": TOOL}, [TOOL, TOOL_11, COMPAT["FEX_TOOL"]]) + self.write_games({"10": {"valve": True, "dir": "Proton 11.0 (ARM64)"}}) + state = self.reconcile(page) + self.assertEqual(page.sets, [("10", TOOL_11)]) + self.assertEqual(state["games_applied"], {"10": {"valve": True, "dir": "Proton 11.0 (ARM64)"}}) + + page.sets.clear() + page.mapping["10"] = COMPAT["FEX_TOOL"] + state = self.reconcile(page) + self.assertEqual(page.sets, []) + self.assertEqual(page.mapping["10"], COMPAT["FEX_TOOL"]) + self.assertEqual(self.games(), {}) + self.assertEqual(state["games_applied"], {}) + + self.write_games({"10": {"valve": True, "dir": "Proton 11.0 (ARM64)"}}) + self.reconcile(page) + self.assertEqual(page.sets, [("10", TOOL_11)]) + + def test_a_steam_pick_of_our_proton_becomes_the_app_choice_live(self): + page = FakePage({"0": TOOL, "10": TOOL, "11": TOOL}, [TOOL, TOOL_11]) + self.write_games({"10": {"valve": True, "dir": "Proton 11.0 (ARM64)"}}) + self.reconcile(page) + page.sets.clear() + page.mapping["10"] = TOOL + state = self.reconcile(page) + self.assertEqual(page.sets, []) + self.assertEqual(self.games(), {"10": {"valve": True, "dir": "Proton Experimental (ARM64)"}}) + self.assertEqual(state["games_applied"], self.games()) + + def test_use_the_default_applies_once_then_steam_picks_stand_live(self): + page = FakePage({"0": TOOL, "10": TOOL_11, "11": TOOL}, [TOOL, TOOL_11, COMPAT["FEX_TOOL"]]) + self.write_games({"10": None}) + self.reconcile(page) + self.assertEqual(page.sets, [("10", TOOL)]) + + page.sets.clear() + self.reconcile(page) + self.assertEqual(self.games(), {}) + page.mapping["10"] = COMPAT["FEX_TOOL"] + self.reconcile(page) + self.assertEqual(page.sets, []) + self.assertEqual(page.mapping["10"], COMPAT["FEX_TOOL"]) + + def test_choices_an_earlier_build_applied_give_way_to_steam_picks_live(self): + (self.state / "state.json").write_text(json.dumps({"default": TOOL, "auto": {"11": TOOL}, "tools": []})) + self.write_games({"10": None, "11": {"valve": True, "dir": "Proton 11.0 (ARM64)"}}) + page = FakePage({"0": TOOL, "10": COMPAT["FEX_TOOL"], "11": "steamlinuxruntime_sniper"}, + [TOOL, TOOL_11, COMPAT["FEX_TOOL"]]) + self.reconcile(page) + self.assertEqual(page.sets, []) + self.assertEqual(self.games(), {}) + + def test_an_app_choice_written_during_a_sync_is_not_overwritten(self): + self.write_games({"10": {"valve": True, "dir": "Proton 11.0 (ARM64)"}}) + requests, text = COMPAT["read_game_requests"]() + self.write_games({"10": {"valve": True, "dir": "Proton Experimental (ARM64)"}}) + self.assertFalse(COMPAT["write_game_requests"]({}, text)) + self.assertEqual(self.games(), {"10": {"valve": True, "dir": "Proton Experimental (ARM64)"}}) + + def test_a_steam_pick_is_kept_at_the_next_start(self): + config = self.steam / "config/config.vdf" + config.parent.mkdir(parents=True) + vdf = '"InstallConfigStore" { "Software" { "Valve" { "Steam" { "CompatToolMapping" { %s } } } } }' + entry = '"%s" { "name" "%s" "config" "" "priority" "250" }' + catalog = COMPAT["tool_catalog"](str(self.steam)) + self.write_games({"10": {"valve": True, "dir": "Proton 11.0 (ARM64)"}}) + config.write_text(vdf % (entry % ("0", TOOL) + entry % ("10", TOOL))) + with mock.patch.dict(COMPAT["register_default"].__globals__, steam_running=lambda: False): + COMPAT["register_default"](str(config), ["10"], catalog=catalog, directory=str(self.state), settle=True) + self.assertIn(entry % ("10", TOOL_11), " ".join(config.read_text().split())) + config.write_text(vdf % (entry % ("0", TOOL) + entry % ("10", COMPAT["FEX_TOOL"]))) + COMPAT["register_default"](str(config), ["10"], catalog=catalog, directory=str(self.state), settle=True) + self.assertIn(entry % ("10", COMPAT["FEX_TOOL"]), " ".join(config.read_text().split())) + self.assertEqual(self.games(), {}) + def test_file_mode_defers_to_the_live_helper(self): (self.state / "live.pid").write_text("%d\n" % os.getpid()) self.assertTrue(COMPAT["live_helper_running"]()) From 449dc301bd1d9320824662568397cea6c0c9afca Mon Sep 17 00:00:00 2001 From: The412Banner Date: Thu, 22 Oct 2026 22:29:03 +0000 Subject: [PATCH 07/59] fix(steam): keep the applied record for a game the client did not report, and apply nothing after a lost write Review: a game the client briefly refused lost its games_applied record, so its unchanged request later looked new and overwrote the Steam pick; it now keeps what was applied before. When the app rewrote games.json during both tries, the stale second read was applied; that pass now applies no per-game choice and keeps the record. --- .../usr/local/bin/droiddeck-steam-compat | 2 +- .../overlay/usr/local/bin/steam-compatibility | 18 ++++++++----- tools/tests/test_steam_compat_live.py | 26 +++++++++++++++++++ 3 files changed, 38 insertions(+), 8 deletions(-) diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-compat b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-compat index e751d1f95..2793365bc 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-compat +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-compat @@ -619,7 +619,7 @@ class Helper: current.pop(app, None) # The client's pick of a tool the app chose for a game wins once the app's choice was applied. requests = COMPAT["settled_game_requests"](state, current, catalog) - overrides = COMPAT["requested_games"](requests, catalog, [t["name"] for t in live]) + overrides = COMPAT["requested_games"](requests or {}, catalog, [t["name"] for t in live]) overrides = {app: tool for app, tool in overrides.items() if app not in refused} changes, auto = COMPAT["plan_mapping"]( current, installed, [t["name"] for t in live], default, diff --git a/tools/linuxfs/overlay/usr/local/bin/steam-compatibility b/tools/linuxfs/overlay/usr/local/bin/steam-compatibility index 055f33ede..69030a6b0 100755 --- a/tools/linuxfs/overlay/usr/local/bin/steam-compatibility +++ b/tools/linuxfs/overlay/usr/local/bin/steam-compatibility @@ -1190,22 +1190,26 @@ def settle_game_requests(requests, state, current, catalog, missing_is_cleared=F def settled_game_requests(state, current, catalog, missing_is_cleared=False, directory=None): """Reads the app's choices, settles them against the client's picks and stores the result. When the - app rewrote the file meanwhile, its newer choices are settled again; they are never overwritten.""" + app rewrote the file meanwhile, its newer choices are settled again; they are never overwritten. + None when the app kept rewriting it: this pass then applies no per-game choice and records nothing.""" for _ in range(2): requests, text = read_game_requests(directory) settled, moved = settle_game_requests(requests, state, current, catalog, missing_is_cleared) if not moved or write_game_requests(settled, text, directory): return settled - return settled + return None def applied_game_requests(requests, overrides, state): - """What the app asked for that is now in the mapping; a tool not installed yet stays unapplied.""" + """What the app asked for that is now in the mapping. A tool not installed yet stays unapplied, and a + game this pass did not handle (the client refused to report it) keeps what was applied before.""" recorded = state.get("games_applied") before = recorded if isinstance(recorded, dict) else {} - return {app: request if overrides.get(app) is not DEFERRED_GAME_TOOL else before.get(app) - for app, request in requests.items() if app in overrides and - (overrides.get(app) is not DEFERRED_GAME_TOOL or app in before)} + if requests is None: + return dict(before) + return {app: request if app in overrides and overrides[app] is not DEFERRED_GAME_TOOL else before[app] + for app, request in requests.items() + if app in overrides and overrides[app] is not DEFERRED_GAME_TOOL or app in before} def requested_games(requests, catalog, runnable=None): @@ -1397,7 +1401,7 @@ def register_default(config_path, apps=(), protect=(), default=None, catalog=Non steam_root = os.path.dirname(os.path.dirname(os.path.abspath(config_path))) # With the client stopped, config.vdf is what it last kept: an app missing from it has no forced tool. requests = settled_game_requests(state, current, catalog, not steam_running(), directory) - overrides = requested_games(requests, catalog) + overrides = requested_games(requests or {}, catalog) managed = state.get("auto", {}).keys() if isinstance(state.get("auto"), dict) else () native_choices = state.get("native_choices", ()) native = native_targets(steam_root, set(apps) | set(picks) | set(managed) | set(native_choices)) diff --git a/tools/tests/test_steam_compat_live.py b/tools/tests/test_steam_compat_live.py index 5b793c313..af2d8cfb6 100644 --- a/tools/tests/test_steam_compat_live.py +++ b/tools/tests/test_steam_compat_live.py @@ -707,6 +707,32 @@ class ReconcileTest(unittest.TestCase): self.assertFalse(COMPAT["write_game_requests"]({}, text)) self.assertEqual(self.games(), {"10": {"valve": True, "dir": "Proton Experimental (ARM64)"}}) + def test_an_app_the_client_briefly_refuses_keeps_its_applied_choice_live(self): + page = FakePage({"0": TOOL, "10": TOOL, "11": TOOL}, [TOOL, TOOL_11, COMPAT["FEX_TOOL"]]) + self.write_games({"10": {"valve": True, "dir": "Proton 11.0 (ARM64)"}}) + self.reconcile(page) + page.refused = {"10"} + state = self.reconcile(page) + self.assertEqual(state["games_applied"], {"10": {"valve": True, "dir": "Proton 11.0 (ARM64)"}}) + page.refused = set() + page.sets.clear() + page.mapping["10"] = COMPAT["FEX_TOOL"] + self.reconcile(page) + self.assertEqual(page.sets, []) + self.assertEqual(page.mapping["10"], COMPAT["FEX_TOOL"]) + + def test_an_app_rewriting_its_choices_during_both_tries_applies_none_of_them_live(self): + page = FakePage({"0": TOOL, "10": TOOL, "11": TOOL}, [TOOL, TOOL_11, COMPAT["FEX_TOOL"]]) + self.write_games({"10": {"valve": True, "dir": "Proton 11.0 (ARM64)"}}) + self.reconcile(page) + page.sets.clear() + page.mapping["10"] = COMPAT["FEX_TOOL"] + self.write_games({"10": {"valve": True, "dir": "Proton 11.0 (ARM64)"}, "11": None}) + with mock.patch.dict(COMPAT["settled_game_requests"].__globals__, write_game_requests=lambda *_: False): + state = self.reconcile(page) + self.assertEqual(page.sets, []) + self.assertEqual(state["games_applied"], {"10": {"valve": True, "dir": "Proton 11.0 (ARM64)"}}) + def test_a_steam_pick_is_kept_at_the_next_start(self): config = self.steam / "config/config.vdf" config.parent.mkdir(parents=True) From 0127fa24aaf4ae81f4f27e65270a7ce40cc090ca Mon Sep 17 00:00:00 2001 From: SuziJay <112842168+ZiYan416@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:36:54 +0800 Subject: [PATCH 08/59] feat: show dot-prefixed folders in game picker --- .../droiddeck/launcher/frontend/AddedGames.kt | 4 +-- .../droiddeck/launcher/ui/ExePickerDialog.kt | 25 ++++++++++++++----- .../droiddeck/launcher/ui/FrontEndGames.kt | 3 ++- .../droiddeck/launcher/ui/PickListingTest.kt | 11 ++++++++ 4 files changed, 34 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt b/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt index 5de835f6e..2a83d57e5 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt @@ -274,7 +274,7 @@ object AddedGames { class FolderResult(val added: List, val already: Int) - fun addFolder(context: Context, dir: File, onGame: (Game) -> Unit = {}, onChecking: (String?) -> Unit = {}): FolderResult { + fun addFolder(context: Context, dir: File, onGame: (Game) -> Unit = {}, onChecking: (String?) -> Unit = {}, showHidden: Boolean = false): FolderResult { val app = context.applicationContext // Binds are mounted at session start, so a new one reaches the session from the next start. if (guestPath(app, dir) == null) bindFolder(app, dir) @@ -283,7 +283,7 @@ object AddedGames { var already = 0 val fresh = ArrayList() val added = ArrayList() - for (sub in dir.listFiles { f -> f.isDirectory && !f.name.startsWith(".") }?.sortedBy { it.name.lowercase() }.orEmpty()) { + for (sub in dir.listFiles { f -> f.isDirectory && (showHidden || !f.name.startsWith(".")) }?.sortedBy { it.name.lowercase() }.orEmpty()) { val path = canonical(sub) if (listed.any { it == path || inside(path, it) || inside(it, path) }) { already++; continue } onChecking(sub.name) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ExePickerDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/ExePickerDialog.kt index 901bf6485..9fd363155 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ExePickerDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ExePickerDialog.kt @@ -27,6 +27,8 @@ import androidx.compose.material.icons.outlined.Image import androidx.compose.material.icons.outlined.InsertDriveFile import androidx.compose.material.icons.outlined.SdCard import androidx.compose.material.icons.outlined.Smartphone +import androidx.compose.material.icons.outlined.Visibility +import androidx.compose.material.icons.outlined.VisibilityOff import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -81,8 +83,8 @@ internal class PickFolder(val dir: File, val readable: Boolean) internal class PickListing(val folders: List, val files: List) -internal fun pickListing(dir: File, kind: PickKind): PickListing { - val all = dir.listFiles()?.filter { !it.name.startsWith(".") }.orEmpty().sortedBy { it.name.lowercase() } +internal fun pickListing(dir: File, kind: PickKind, showHidden: Boolean = false): PickListing { + val all = dir.listFiles()?.filter { showHidden || !it.name.startsWith(".") }.orEmpty().sortedBy { it.name.lowercase() } return PickListing( all.filter { it.isDirectory }.map { PickFolder(it, it.canRead() && it.list() != null) }, all.filter { it.isFile && it.extension.lowercase() in kind.extensions }, @@ -92,11 +94,13 @@ internal fun pickListing(dir: File, kind: PickKind): PickListing { @Composable internal fun ExePickerDialog( title: String, kind: PickKind = PickKind.EXE, start: File? = null, - onAddAll: ((File) -> Unit)? = null, onPick: (File) -> Unit, onDismiss: () -> Unit, + onAddAll: ((File, Boolean) -> Unit)? = null, onPick: (File) -> Unit, onDismiss: () -> Unit, ) { val ctx = LocalContext.current val colors = MaterialTheme.colorScheme val density = LocalDensity.current + val prefs = remember(ctx) { ctx.getSharedPreferences("game_picker", Context.MODE_PRIVATE) } + var showHidden by remember { mutableStateOf(prefs.getBoolean("show_hidden", false)) } val shown = rememberShown(onDismiss) val close = { shown.targetState = false } val volumes = remember { pickVolumes(ctx) } @@ -107,14 +111,23 @@ internal fun ExePickerDialog( val back = { if (dir == null) close() else up() } val first = remember(dir) { FocusRequester() } LaunchedEffect(dir) { focusWithinFrames({ false }) { first } } - val listing = remember(dir) { dir?.let { pickListing(it, kind) } } + val listing = remember(dir, showHidden) { dir?.let { pickListing(it, kind, showHidden) } } var locked by remember { mutableStateOf(null) } AppDialog( shown, close, "filePicker", wide = false, maxWidth = 520.dp, width = locked, fitContent = locked == null, back = back, modifier = Modifier.onSizeChanged { if (locked == null && it.width > 0) locked = with(density) { it.width.toDp() } }, ) { Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) { - DialogTitleRow(title, onClose = close) + DialogTitleRow(title, onClose = close, leading = { + SmallIconButton( + if (showHidden) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, + stringResource(if (showHidden) R.string.fm_hide_hidden else R.string.fm_show_hidden), + if (showHidden) colors.primary else colors.onSurfaceVariant, + ) { + showHidden = !showHidden + prefs.edit().putBoolean("show_hidden", showHidden).apply() + } + }) if (dir != null && volume != null) { val crumbs = listOf(volume.label) + dir!!.path.removePrefix(volume.root.path).split('/').filter { it.isNotEmpty() } Text(crumbs.joinToString(" › "), fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) @@ -135,7 +148,7 @@ internal fun ExePickerDialog( val shownListing = listing ?: PickListing(emptyList(), emptyList()) PickerRow(Icons.AutoMirrored.Outlined.ArrowBack, "..", null, focus = first, onClick = up) if (onAddAll != null && shownListing.folders.any { it.readable }) { - PickerRow(Icons.Filled.Add, stringResource(R.string.games_add_all), null, accent = true) { onAddAll(dir!!); close() } + PickerRow(Icons.Filled.Add, stringResource(R.string.games_add_all), null, accent = true) { onAddAll(dir!!, showHidden); close() } } for (f in shownListing.folders) key(f.dir.path) { PickerRow(Icons.Outlined.Folder, f.dir.name, null, enabled = f.readable) { dir = f.dir } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt index c7b78ee05..477ab9c50 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt @@ -424,7 +424,7 @@ private fun GamesDialogs(a: FrontEndActions, games: List) { val st = GamesDialogState if (st.adding) ExePickerDialog( stringResource(R.string.games_add_title), - onAddAll = { dir -> + onAddAll = { dir, showHidden -> st.summary = emptyList() to 0 st.checking = dir.name scope.launch { @@ -433,6 +433,7 @@ private fun GamesDialogs(a: FrontEndActions, games: List) { ctx.applicationContext, dir, onGame = { g -> scope.launch { st.summary?.let { (list, n) -> st.summary = (list + g) to n } } }, onChecking = { name -> scope.launch { st.checking = name } }, + showHidden = showHidden, ) } st.checking = null diff --git a/app/src/test/java/com/droiddeck/launcher/ui/PickListingTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/PickListingTest.kt index 9ed572671..d1487fcac 100644 --- a/app/src/test/java/com/droiddeck/launcher/ui/PickListingTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/ui/PickListingTest.kt @@ -29,6 +29,17 @@ class PickListingTest { assertEquals(listOf("alarms", "Android", "DCIM", "Documents", "Download", "Games", "Winlator"), listing.folders.map { it.dir.name }) } + @Test fun hiddenDirectoriesAndExecutablesCanBeShownOnRequest() { + val root = storage() + val hidden = File(root, ".Games").apply { mkdirs() } + File(hidden, "Game.exe").writeText("game") + assertFalse(pickListing(root, PickKind.EXE).folders.any { it.dir.name == ".Games" }) + val listing = pickListing(root, PickKind.EXE, showHidden = true) + assertTrue(listing.folders.any { it.dir.name == ".Games" }) + assertTrue(listing.files.any { it.name == ".hidden.exe" }) + assertEquals(listOf("Game.exe"), pickListing(hidden, PickKind.EXE).files.map { it.name }) + } + @Test fun onlyExesForAGameOnlyImagesForArt() { val root = storage() assertEquals(listOf("game.EXE", "Setup.exe"), pickListing(root, PickKind.EXE).files.map { it.name }) From 4bcd4e3cca3a4ff2398b2f327a4c21c1ed678b27 Mon Sep 17 00:00:00 2001 From: maxjivi05 Date: Fri, 23 Oct 2026 09:02:47 -0400 Subject: [PATCH 09/59] perf: droiddeck-ntsync without signal-mask syscalls, waits that skip the queue, and per-thread device references --- README.md | 2 +- tools/linuxfs/preload/ntsync.c | 283 +++++++++++++----- tools/tests/test_ntsync_preload.py | 456 +++++++++++++++++++++++++++++ 3 files changed, 671 insertions(+), 70 deletions(-) create mode 100644 tools/tests/test_ntsync_preload.py diff --git a/README.md b/README.md index fb86fa653..7fd8cfbdb 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ Compatibility and performance vary by device; hardware validation is limited. De ## Credits and licence -GPL-3.0. Runtime, shim, input, and controller work build on WinNative and Bannerlator (maxjivi05). Epic Games and GOG support build on [Phobos’s](https://github.com/phobos665) adaptation of [Legendary](https://github.com/legendary-gl/legendary) and [gogdl](https://github.com/Heroic-Games-Launcher/heroic-gogdl), respectively. The registry entries a GOG game's install script (`goggame-.script`) sets are read the way [Heroic Games Launcher](https://github.com/Heroic-Games-Launcher/HeroicGamesLauncher)'s GOG setup (GPL-3.0) reads them. LSFG frame generation is from the work of Camille LaVey and the [Eden](https://eden-emu.dev) emulator project, following [lsfg-vk](https://github.com/PancakeTAS/lsfg-vk), ported to WinNative and DroidDeck by [@maxjivi05](https://github.com/maxjivi05); it needs your own copy of [Lossless Scaling](https://store.steampowered.com/app/993090/) and ships none of its shaders. x86 AppImages, and any whose own runtime cannot unpack them, are unpacked with [uruntime](https://github.com/VHSgunzo/uruntime) by VHSgunzo (MIT), shipped unmodified with its licence, which also starts the arm64 emulator AppImages whose own runtime cannot run its extractor on Android. The Steam client's audio goes through [@The412Banner](https://github.com/The412Banner)'s [DirectAudio](https://github.com/The412Banner/directaudio) PulseAudio sink (LGPL-2.1-or-later), built from `tools/directaudio/sink` with its request size kept to its ring so clients are not asked to queue 100 ms ahead. mmap ring-buffer controller input was developed by ribbit384 ([WinNative #345](https://github.com/WinNative-Emu/WinNative/pull/345)). Why the x86-64 Steam client refused the arm64 client's sign-in was checked by reading Steam's saved token with the `ConnectCache` format from [deadlock-api-ingest](https://github.com/deadlock-api/deadlock-api-ingest). x86 programs that link OpenSSL 3 under FEX use the copy in the [Steam Linux Runtime 4.0](https://gitlab.steamos.cloud/steamrt/steamrt) platform. Hooks written with `WriteProcessMemory` into code [FEX](https://github.com/FEX-Emu/FEX) has translated go through `/proc//mem` again when the kernel refuses `process_vm_writev`, as [Wine](https://gitlab.winehq.org/wine/wine)'s `server/ptrace.c` wrote them before Wine 11. x86 Linux games under FEX get the same `/etc/asound.conf` that [pressure-vessel](https://gitlab.steamos.cloud/steamrt/steam-runtime-tools) writes into the Steam Linux Runtime, so ALSA plays through PulseAudio. Games render with [Mesa](https://gitlab.freedesktop.org/mesa/mesa) Turnip's own per-render-pass choice between tile memory and system memory, while the Steam client keeps `TU_DEBUG=sysmem`; [FEX](https://github.com/FEX-Emu/FEX)'s code disk cache was checked in its source and is already on through Proton. The proot fast path picks the guest name of a host file bound under several names in the order [PRoot](https://github.com/proot-me/proot)'s `path/binding.c` (GPL-2.0-or-later) sorts its bindings, so links and `/proc/self/fd` read back as they do through proot. The session's [PipeWire](https://gitlab.freedesktop.org/pipewire/pipewire) (MIT) opens its sockets with the per-socket access settings documented in its `module-access.c`, and [WirePlumber](https://gitlab.freedesktop.org/pipewire/wireplumber) (MIT) keeps those clients at full permissions through its `access.rules`. Wine write watches use the userfaultfd support in [Proton](https://github.com/ValveSoftware/wine)'s `dlls/ntdll/unix/virtual.c` (LGPL-2.1-or-later): libblsession gives it the arm64 syscall number and answers its `PAGEMAP_SCAN` from `/proc/self/pagemap` where Android refuses the ioctl. Wine on arm64 sets a thread's full context by raising `SIGUSR2` from `syscall_dispatcher_return_slowpath` in [Wine](https://gitlab.winehq.org/wine/wine)'s `dlls/ntdll/unix/signal_arm64.c` (LGPL-2.1-or-later); libblsession runs that handler in place on a frame laid out as the [Linux](https://github.com/torvalds/linux) kernel's `arch/arm64/kernel/signal.c` builds it and returns through `rt_sigreturn`, so proot does not stop the thread for it. Wine's process list reads each process's memory counters from `/proc//status` in `fill_vm_counters` ([Wine](https://gitlab.winehq.org/wine/wine) `dlls/ntdll/unix/process.c`, LGPL-2.1-or-later), so libblsession gives those reads the kernel's file instead of a copy with `TracerPid` cleared. See [LICENSE](LICENSE). Steam and Proton belong to Valve Corporation; this project is not affiliated with Valve. +GPL-3.0. Runtime, shim, input, and controller work build on WinNative and Bannerlator (maxjivi05). Epic Games and GOG support build on [Phobos’s](https://github.com/phobos665) adaptation of [Legendary](https://github.com/legendary-gl/legendary) and [gogdl](https://github.com/Heroic-Games-Launcher/heroic-gogdl), respectively. The registry entries a GOG game's install script (`goggame-.script`) sets are read the way [Heroic Games Launcher](https://github.com/Heroic-Games-Launcher/HeroicGamesLauncher)'s GOG setup (GPL-3.0) reads them. LSFG frame generation is from the work of Camille LaVey and the [Eden](https://eden-emu.dev) emulator project, following [lsfg-vk](https://github.com/PancakeTAS/lsfg-vk), ported to WinNative and DroidDeck by [@maxjivi05](https://github.com/maxjivi05); it needs your own copy of [Lossless Scaling](https://store.steampowered.com/app/993090/) and ships none of its shaders. x86 AppImages, and any whose own runtime cannot unpack them, are unpacked with [uruntime](https://github.com/VHSgunzo/uruntime) by VHSgunzo (MIT), shipped unmodified with its licence, which also starts the arm64 emulator AppImages whose own runtime cannot run its extractor on Android. The Steam client's audio goes through [@The412Banner](https://github.com/The412Banner)'s [DirectAudio](https://github.com/The412Banner/directaudio) PulseAudio sink (LGPL-2.1-or-later), built from `tools/directaudio/sink` with its request size kept to its ring so clients are not asked to queue 100 ms ahead. mmap ring-buffer controller input was developed by ribbit384 ([WinNative #345](https://github.com/WinNative-Emu/WinNative/pull/345)). Why the x86-64 Steam client refused the arm64 client's sign-in was checked by reading Steam's saved token with the `ConnectCache` format from [deadlock-api-ingest](https://github.com/deadlock-api/deadlock-api-ingest). x86 programs that link OpenSSL 3 under FEX use the copy in the [Steam Linux Runtime 4.0](https://gitlab.steamos.cloud/steamrt/steamrt) platform. Hooks written with `WriteProcessMemory` into code [FEX](https://github.com/FEX-Emu/FEX) has translated go through `/proc//mem` again when the kernel refuses `process_vm_writev`, as [Wine](https://gitlab.winehq.org/wine/wine)'s `server/ptrace.c` wrote them before Wine 11. x86 Linux games under FEX get the same `/etc/asound.conf` that [pressure-vessel](https://gitlab.steamos.cloud/steamrt/steam-runtime-tools) writes into the Steam Linux Runtime, so ALSA plays through PulseAudio. Games render with [Mesa](https://gitlab.freedesktop.org/mesa/mesa) Turnip's own per-render-pass choice between tile memory and system memory, while the Steam client keeps `TU_DEBUG=sysmem`; [FEX](https://github.com/FEX-Emu/FEX)'s code disk cache was checked in its source and is already on through Proton. The proot fast path picks the guest name of a host file bound under several names in the order [PRoot](https://github.com/proot-me/proot)'s `path/binding.c` (GPL-2.0-or-later) sorts its bindings, so links and `/proc/self/fd` read back as they do through proot. The session's [PipeWire](https://gitlab.freedesktop.org/pipewire/pipewire) (MIT) opens its sockets with the per-socket access settings documented in its `module-access.c`, and [WirePlumber](https://gitlab.freedesktop.org/pipewire/wireplumber) (MIT) keeps those clients at full permissions through its `access.rules`. Wine write watches use the userfaultfd support in [Proton](https://github.com/ValveSoftware/wine)'s `dlls/ntdll/unix/virtual.c` (LGPL-2.1-or-later): libblsession gives it the arm64 syscall number and answers its `PAGEMAP_SCAN` from `/proc/self/pagemap` where Android refuses the ioctl. Wine on arm64 sets a thread's full context by raising `SIGUSR2` from `syscall_dispatcher_return_slowpath` in [Wine](https://gitlab.winehq.org/wine/wine)'s `dlls/ntdll/unix/signal_arm64.c` (LGPL-2.1-or-later); libblsession runs that handler in place on a frame laid out as the [Linux](https://github.com/torvalds/linux) kernel's `arch/arm64/kernel/signal.c` builds it and returns through `rt_sigreturn`, so proot does not stop the thread for it. Wine's process list reads each process's memory counters from `/proc//status` in `fill_vm_counters` ([Wine](https://gitlab.winehq.org/wine/wine) `dlls/ntdll/unix/process.c`, LGPL-2.1-or-later), so libblsession gives those reads the kernel's file instead of a copy with `TracerPid` cleared. droiddeck-ntsync is checked against the object, wait and error semantics documented for the [Linux](https://github.com/torvalds/linux) ntsync driver in `Documentation/userspace-api/ntsync.rst` (GPL-2.0). See [LICENSE](LICENSE). Steam and Proton belong to Valve Corporation; this project is not affiliated with Valve. XR glasses support builds on WinNative's VITURE glasses work. VITURE protocol details were checked against [XRLinuxDriver](https://github.com/wheaney/XRLinuxDriver) by [@wheaney](https://github.com/wheaney), including the VITURE protocol header it ships, [ar-drivers-rs](https://github.com/badicsalex/ar-drivers-rs) by [@badicsalex](https://github.com/badicsalex) and the [go-macos/iokit VITURE package](https://pkg.go.dev/github.com/go-macos/iokit/viture); the optional VITURE SDK belongs to VITURE and is not included. RayNeo support adapts [@informalTechCode](https://github.com/informalTechCode)'s [RayNeo-Air-Series-3DoF-Scaffold](https://github.com/informalTechCode/RayNeo-Air-Series-3DoF-Scaffold) (Apache-2.0) and references [@verncat](https://github.com/verncat)'s [RayNeo-Air-3S-Pro-OpenVR](https://github.com/verncat/RayNeo-Air-3S-Pro-OpenVR) (MIT) and [Monado](https://gitlab.freedesktop.org/monado/monado) (BSL-1.0); see [docs/third-party/rayneo](docs/third-party/rayneo/README.md). XREAL 1S support references [XRLinuxDriver](https://github.com/wheaney/XRLinuxDriver) and [xrealOneDeviceKit](https://github.com/wheaney/xrealOneDeviceKit) by @wheaney, [xreal_one_driver](https://github.com/rohitsangwan01/xreal_one_driver) by [@rohitsangwan01](https://github.com/rohitsangwan01) (MIT) and [xr-tools](https://github.com/0xcaff/xr-tools) by [@0xcaff](https://github.com/0xcaff). Rokid support follows the Rokid protocol notes in [ar-drivers-rs](https://github.com/badicsalex/ar-drivers-rs) and @badicsalex's [Void Computing write-ups](https://voidcomputing.hu/blog/worse-better-prettier/), and [Monado](https://gitlab.freedesktop.org/monado/monado)'s Rokid driver. The REDMAGIC glasses-display findings draw on [@mekhontsev](https://github.com/mekhontsev)'s [MagicDesk investigation](https://github.com/mekhontsev/magicdesk/blob/main/docs/xr-resolution-fix.md) and [@Coding-BR](https://github.com/Coding-BR)'s [decompiled Nubia kernel](https://github.com/Coding-BR/android_kernel_nubia_sm8850_qwjujube). VITURE, RayNeo, XREAL, Rokid and REDMAGIC are trademarks of their owners; this project is not affiliated with them. diff --git a/tools/linuxfs/preload/ntsync.c b/tools/linuxfs/preload/ntsync.c index b920b49f9..2b1c79045 100644 --- a/tools/linuxfs/preload/ntsync.c +++ b/tools/linuxfs/preload/ntsync.c @@ -68,12 +68,13 @@ struct ns_wait_args { #define NS_PROCS 256u #define NS_FDS 65536 #define NS_DEVS 8 -#define NS_VERSION 1u +#define NS_VERSION 2u #define NS_HEAD_MAGIC 0x31434e5953544e44ull #define NS_DEV_MAGIC 0x4e54445600000000ull #define NS_OBJ_MAGIC 0x27ull #define NS_WAITERS 0x80000000u #define NS_SWEEP_NS 2000000000ull +#define NS_SPIN_NS 4000ull #define NS_DEAD 0xffffffffu #ifndef CLOSE_RANGE_CLOEXEC @@ -144,6 +145,7 @@ struct ns_q { uint32_t waiting; uint32_t inuse; uint32_t next_free; + uint32_t sleeping; struct ns_ent ent[NS_ENTS]; }; @@ -255,19 +257,104 @@ static void ns_unlock(uint32_t *w) { } } -static void ns_block(sigset_t *old) { - sigset_t s; - sigfillset(&s); - sigdelset(&s, SIGSEGV); - sigdelset(&s, SIGBUS); - sigdelset(&s, SIGILL); - sigdelset(&s, SIGFPE); - sigdelset(&s, SIGTRAP); - pthread_sigmask(SIG_BLOCK, &s, old); +#define NS_DEFER 4 + +struct ns_cs { + int depth; + int count; + int sigs[NS_DEFER]; + siginfo_t info[NS_DEFER]; +}; + +static __thread struct ns_cs ns_cs; +static uintptr_t ns_handlers[_NSIG]; +static uint8_t ns_siginfo[_NSIG]; + +int bl_from_ntdll(const void *caller) __attribute__((visibility("hidden"))); + +static int ns_deferrable(int sig) { + return sig > 0 && sig < _NSIG && sig != SIGKILL && sig != SIGSTOP && sig != SIGSEGV && sig != SIGBUS && sig != SIGILL + && sig != SIGFPE && sig != SIGTRAP && sig != SIGSYS; } -static void ns_restore(const sigset_t *old) { - pthread_sigmask(SIG_SETMASK, old, NULL); +static int ns_defer(int sig, const siginfo_t *si) { + struct ns_cs *cs = &ns_cs; + if (sig < SIGRTMIN) + for (int i = 0; i < cs->count; i++) + if (cs->sigs[i] == sig) return 1; + if (cs->count >= NS_DEFER) return 0; + cs->sigs[cs->count] = sig; + if (si) memcpy(&cs->info[cs->count], si, sizeof(*si)); + else memset(&cs->info[cs->count], 0, sizeof(*si)); + cs->count++; + return 1; +} + +static void ns_trampoline(int sig, siginfo_t *si, void *uc) { + if (__atomic_load_n(&ns_cs.depth, __ATOMIC_RELAXED) > 0 && ns_defer(sig, si)) return; + uintptr_t h = __atomic_load_n(&ns_handlers[sig], __ATOMIC_ACQUIRE); + if (!h) return; + if (__atomic_load_n(&ns_siginfo[sig], __ATOMIC_RELAXED)) ((void (*)(int, siginfo_t *, void *))h)(sig, si, uc); + else ((void (*)(int))h)(sig); +} + +static void ns_flush(void) { + struct ns_cs *cs = &ns_cs; + int n = cs->count, sigs[NS_DEFER]; + siginfo_t info[NS_DEFER]; + memcpy(sigs, cs->sigs, sizeof(sigs)); + memcpy(info, cs->info, sizeof(info)); + cs->count = 0; + int saved = errno; + pid_t pid = getpid(), tid = (pid_t)ns_self(); + for (int i = 0; i < n; i++) + if (syscall(SYS_rt_tgsigqueueinfo, pid, tid, sigs[i], &info[i]) != 0) syscall(SYS_tgkill, pid, tid, sigs[i]); + errno = saved; +} + +static void ns_block(void) { + ns_cs.depth++; + __atomic_signal_fence(__ATOMIC_SEQ_CST); +} + +static void ns_restore(void) { + __atomic_signal_fence(__ATOMIC_SEQ_CST); + if (--ns_cs.depth == 0) { + __atomic_signal_fence(__ATOMIC_SEQ_CST); + if (ns_cs.count) ns_flush(); + } +} + +int sigaction(int sig, const struct sigaction *act, struct sigaction *old) { + static int (*real)(int, const struct sigaction *, struct sigaction *); + if (!real) real = (int (*)(int, const struct sigaction *, struct sigaction *))dlsym(RTLD_NEXT, "sigaction"); + if (!real) { + errno = ENOSYS; + return -1; + } + if (!ns_deferrable(sig) || !ns_on()) return real(sig, act, old); + uintptr_t prev = __atomic_load_n(&ns_handlers[sig], __ATOMIC_ACQUIRE); + uint8_t prev_info = __atomic_load_n(&ns_siginfo[sig], __ATOMIC_RELAXED); + struct sigaction mine; + const struct sigaction *use = act; + if (act && act->sa_handler != SIG_DFL && act->sa_handler != SIG_IGN && bl_from_ntdll(__builtin_return_address(0))) { + mine = *act; + mine.sa_sigaction = ns_trampoline; + mine.sa_flags |= SA_SIGINFO; + __atomic_store_n(&ns_siginfo[sig], (act->sa_flags & SA_SIGINFO) ? 1 : 0, __ATOMIC_RELAXED); + __atomic_store_n(&ns_handlers[sig], (uintptr_t)act->sa_sigaction, __ATOMIC_RELEASE); + use = &mine; + } + int r = real(sig, use, old); + if (r != 0 && use == &mine) { + __atomic_store_n(&ns_siginfo[sig], prev_info, __ATOMIC_RELAXED); + __atomic_store_n(&ns_handlers[sig], prev, __ATOMIC_RELEASE); + } + if (r == 0 && old && old->sa_sigaction == ns_trampoline) { + old->sa_sigaction = (void (*)(int, siginfo_t *, void *))prev; + if (!prev_info) old->sa_flags &= ~SA_SIGINFO; + } + return r; } static inline struct ns_obj *ns_obj(struct ns_head *h, uint32_t s) { @@ -378,10 +465,11 @@ static int ns_claim(struct ns_q *q, int32_t index) { if (dead) return 0; } int32_t want = -1; - return __atomic_compare_exchange_n(&q->signaled, &want, index, 0, __ATOMIC_ACQ_REL, __ATOMIC_RELAXED); + return __atomic_compare_exchange_n(&q->signaled, &want, index, 0, __ATOMIC_SEQ_CST, __ATOMIC_RELAXED); } static void ns_wake(struct ns_q *q) { + if (!__atomic_load_n(&q->sleeping, __ATOMIC_SEQ_CST)) return; int saved = errno; ns_futex(&q->signaled, FUTEX_WAKE, 1, NULL, 0); errno = saved; @@ -618,14 +706,13 @@ static void ns_dev_put_locked(struct ns_dev *d) { static void ns_dev_put(struct ns_dev *d) { if (__atomic_sub_fetch(&d->refs, 1, __ATOMIC_ACQ_REL)) return; - sigset_t old; int saved = errno; - ns_block(&old); + ns_block(); pthread_mutex_lock(&ns_mx); uint32_t zero = 0; if (d->h && __atomic_compare_exchange_n(&d->refs, &zero, NS_DEAD, 0, __ATOMIC_ACQ_REL, __ATOMIC_ACQUIRE)) ns_release(d); pthread_mutex_unlock(&ns_mx); - ns_restore(&old); + ns_restore(); errno = saved; } @@ -682,20 +769,18 @@ static void ns_forget_locked(int fd) { } static void ns_forget(int fd) { - sigset_t old; int saved = errno; - ns_block(&old); + ns_block(); pthread_mutex_lock(&ns_mx); ns_forget_locked(fd); pthread_mutex_unlock(&ns_mx); - ns_restore(&old); + ns_restore(); errno = saved; } static uint64_t ns_adopt(struct ns_dev *d, uint32_t tag, int fd, uint32_t gen, uint32_t s) { uint64_t e = 0; - sigset_t old; - ns_block(&old); + ns_block(); pthread_mutex_lock(&ns_mx); if (!d->h || d->tag != tag || !ns_table() || ns_attach(d) < 0 || s >= NS_SLOTS) goto out; if ((e = __atomic_load_n(&ns_fdt[fd], __ATOMIC_ACQUIRE))) goto out; @@ -710,7 +795,7 @@ static uint64_t ns_adopt(struct ns_dev *d, uint32_t tag, int fd, uint32_t gen, u ns_unlock(&o->lock); out: pthread_mutex_unlock(&ns_mx); - ns_restore(&old); + ns_restore(); return e; } @@ -719,8 +804,7 @@ static uint64_t ns_note_device(int fd) { uint64_t e = 0; if (syscall(SYS_fstat, fd, &st) != 0 || !S_ISREG(st.st_mode)) return 0; if (((uint64_t)st.st_size & ~0xffffull) != NS_DEV_MAGIC) return 0; - sigset_t old; - ns_block(&old); + ns_block(); pthread_mutex_lock(&ns_mx); struct ns_dev *d = ns_table() ? ns_map(fd, (uint32_t)st.st_size & 0xffff, (uint64_t)st.st_ino) : NULL; if (d) { @@ -732,7 +816,7 @@ static uint64_t ns_note_device(int fd) { } } pthread_mutex_unlock(&ns_mx); - ns_restore(&old); + ns_restore(); return e; } @@ -773,15 +857,14 @@ static int ns_ready(struct ns_dev *d) { static void ns_thread_gone(void *p) { struct ns_tls *t = p; - if (!t || !t->d || !t->q) return; - sigset_t old; + if (!t || !t->d) return; struct ns_dev *d = t->d; - ns_block(&old); - ns_free_q(d->h, t->q - 1); + ns_block(); + if (t->q) ns_free_q(d->h, t->q - 1); t->q = 0; t->waiting = 0; t->d = NULL; - ns_restore(&old); + ns_restore(); ns_dev_put(d); } @@ -789,24 +872,36 @@ static void ns_key_init(void) { pthread_key_create(&ns_key, ns_thread_gone); } -static struct ns_q *ns_thread_q(struct ns_dev *d, uint32_t *qi) { - struct ns_head *h = d->h; - if (ns_tq.d && ns_tq.q && ns_tq.waiting) { - ns_drop_q(ns_tq.d->h, ns_tq.q - 1); - ns_tq.waiting = 0; - } - if (ns_tq.d == d && ns_tq.q) { - *qi = ns_tq.q - 1; - return ns_q(h, *qi); - } - if (ns_tq.d && ns_tq.q) { - struct ns_dev *old = ns_tq.d; - ns_free_q(old->h, ns_tq.q - 1); +static int ns_hold(struct ns_dev *d) { + if (ns_tq.d == d) return 1; + if (!ns_dev_get(d)) return 0; + struct ns_dev *old = ns_tq.d; + if (old) { + ns_block(); + if (ns_tq.q) ns_free_q(old->h, ns_tq.q - 1); ns_tq.q = 0; + ns_tq.waiting = 0; ns_tq.d = NULL; + ns_restore(); ns_dev_put(old); } pthread_once(&ns_once, ns_key_init); + ns_tq.d = d; + pthread_setspecific(ns_key, &ns_tq); + return 1; +} + +static struct ns_q *ns_thread_q(struct ns_dev *d, uint32_t *qi) { + struct ns_head *h = d->h; + if (ns_tq.d != d) return NULL; + if (ns_tq.q && ns_tq.waiting) { + ns_drop_q(h, ns_tq.q - 1); + ns_tq.waiting = 0; + } + if (ns_tq.q) { + *qi = ns_tq.q - 1; + return ns_q(h, *qi); + } uint32_t i; if (!ns_pop(h, &h->q_free, 0, &i) && !ns_grow(&h->q_hwm, NS_QS, &i)) { ns_sweep_dead(h); @@ -815,22 +910,19 @@ static struct ns_q *ns_thread_q(struct ns_dev *d, uint32_t *qi) { struct ns_q *q = ns_q(h, i); q->proc = (uint32_t)d->proc; q->waiting = 0; + q->sleeping = 0; q->inuse = 1; - __atomic_add_fetch(&d->refs, 1, __ATOMIC_ACQ_REL); - ns_tq.d = d; ns_tq.q = i + 1; ns_tq.waiting = 0; - pthread_setspecific(ns_key, &ns_tq); *qi = i; return q; } static int ns_create(struct ns_dev *d, int type, uint32_t a, uint32_t b) { struct ns_head *h = d->h; - sigset_t old; int fd = -1, err = 0; uint32_t s = 0; - ns_block(&old); + ns_block(); pthread_mutex_lock(&ns_mx); if (!ns_table() || ns_attach(d) < 0) { err = ENOMEM; @@ -879,7 +971,7 @@ static int ns_create(struct ns_dev *d, int type, uint32_t a, uint32_t b) { ns_unlock(&o->lock); out: pthread_mutex_unlock(&ns_mx); - ns_restore(&old); + ns_restore(); if (fd < 0) errno = err; return fd; } @@ -890,6 +982,20 @@ static int ns_expired(const struct ns_wait_args *args) { return (uint64_t)now.tv_sec * 1000000000ull + (uint64_t)now.tv_nsec >= args->timeout; } +static uint64_t ns_now(void) { + struct timespec now; + clock_gettime(CLOCK_MONOTONIC, &now); + return (uint64_t)now.tv_sec * 1000000000ull + (uint64_t)now.tv_nsec; +} + +static void ns_spin(struct ns_q *q) { + uint64_t end = ns_now() + NS_SPIN_NS; + for (unsigned i = 1; __atomic_load_n(&q->signaled, __ATOMIC_ACQUIRE) == -1; i++) { + ns_relax(); + if (!(i & 63) && ns_now() >= end) return; + } +} + static int ns_sleep(struct ns_q *q, const struct ns_wait_args *args) { struct timespec ts, *tsp = NULL; int op = FUTEX_WAIT_BITSET | ((args->flags & NS_WAIT_REALTIME) ? FUTEX_CLOCK_REALTIME : 0); @@ -898,14 +1004,45 @@ static int ns_sleep(struct ns_q *q, const struct ns_wait_args *args) { ts.tv_nsec = (long)(args->timeout % 1000000000ull); tsp = &ts; } - while (__atomic_load_n(&q->signaled, __ATOMIC_ACQUIRE) == -1) { - if (tsp && ns_expired(args)) return ETIMEDOUT; - if (ns_futex(&q->signaled, op, (uint32_t)-1, tsp, FUTEX_BITSET_MATCH_ANY) == -1) { - if (errno == ETIMEDOUT) return ETIMEDOUT; - if (errno == EINTR) return EINTR; - } + ns_spin(q); + int ret = 0; + __atomic_store_n(&q->sleeping, 1, __ATOMIC_SEQ_CST); + while (!ret && __atomic_load_n(&q->signaled, __ATOMIC_SEQ_CST) == -1) { + if (tsp && ns_expired(args)) ret = ETIMEDOUT; + else if (ns_futex(&q->signaled, op, (uint32_t)-1, tsp, FUTEX_BITSET_MATCH_ANY) == -1 && (errno == ETIMEDOUT || errno == EINTR)) + ret = errno; } - return 0; + __atomic_store_n(&q->sleeping, 0, __ATOMIC_RELAXED); + return ret; +} + +static int ns_try_any(struct ns_head *h, const uint32_t *slots, const uint32_t *gens, uint32_t total, uint32_t owner, int *dead) { + for (uint32_t i = 0; i < total; i++) { + struct ns_obj *o = ns_obj(h, slots[i]); + ns_lock(&o->lock); + int busy = o->type == NS_FREE || o->gen != gens[i] || o->any_head || o->all_hint; + if (!busy && ns_signaled(o, owner)) { + switch (o->type) { + case NS_SEM: + o->a--; + break; + case NS_MUTEX: + *dead = (int)o->ownerdead; + o->ownerdead = 0; + o->a++; + o->b = owner; + break; + case NS_EVENT: + if (!o->a) o->b = 0; + break; + } + ns_unlock(&o->lock); + return (int)i; + } + ns_unlock(&o->lock); + if (busy) return -1; + } + return -2; } static int ns_wait(struct ns_dev *d, struct ns_wait_args *ua, int all) { @@ -927,12 +1064,23 @@ static int ns_wait(struct ns_dev *d, struct ns_wait_args *ua, int all) { for (uint32_t j = 0; j < i; j++) if (slots[j] == slots[i]) return EINVAL; } - sigset_t old; - ns_block(&old); + ns_block(); + if (!all && !ns_tq.waiting) { + int dead = 0, got = ns_try_any(h, slots, gens, total, args.owner, &dead); + if (got >= 0) { + ns_restore(); + ua->index = (uint32_t)got; + return dead ? EOWNERDEAD : 0; + } + if (got == -2 && args.timeout != UINT64_MAX && ns_expired(&args)) { + ns_restore(); + return ETIMEDOUT; + } + } uint32_t qi; struct ns_q *q = ns_ready(d) ? ns_thread_q(d, &qi) : NULL; if (!q) { - ns_restore(&old); + ns_restore(); return ENOMEM; } ns_maybe_sweep(h); @@ -1004,16 +1152,15 @@ static int ns_wait(struct ns_dev *d, struct ns_wait_args *ua, int all) { } int ret = 0; if (!bad && __atomic_load_n(&q->signaled, __ATOMIC_ACQUIRE) == -1) { - sigset_t held; - ns_restore(&old); + ns_restore(); ret = ns_sleep(q, &args); - ns_block(&held); + ns_block(); } ns_drop_q(h, qi); ns_tq.waiting = 0; int32_t got = __atomic_load_n(&q->signaled, __ATOMIC_ACQUIRE); int dead = (int)q->ownerdead; - ns_restore(&old); + ns_restore(); if (bad) return EINVAL; if (got != -1) { ua->index = (uint32_t)got; @@ -1061,12 +1208,11 @@ static int ns_obj_ioctl(struct ns_dev *d, uint64_t e, unsigned long req, void *a return ENOTTY; } if (type != (uint32_t)want) return EINVAL; - sigset_t old; - ns_block(&old); + ns_block(); int all = ns_lock_obj(h, o); if (o->type != (uint32_t)want || o->gen != gen) { ns_unlock_obj(h, o, all); - ns_restore(&old); + ns_restore(); return EINVAL; } switch (req) { @@ -1133,7 +1279,7 @@ static int ns_obj_ioctl(struct ns_dev *d, uint64_t e, unsigned long req, void *a } } ns_unlock_obj(h, o, all); - ns_restore(&old); + ns_restore(); if (ret) return ret; if (req == NS_SEM_RELEASE || req == NS_EVENT_SET || req == NS_EVENT_RESET || req == NS_EVENT_PULSE) memcpy(arg, &prev, sizeof(prev)); @@ -1176,9 +1322,8 @@ int bl_ntsync_ioctl(int fd, unsigned long req, void *arg, int *rc) { uint64_t e = ns_lookup(fd); if (!e) return 0; struct ns_dev *d = &ns_devs[(e >> 56) & 7]; - if (!ns_dev_get(d)) return 0; + if (!ns_hold(d)) return 0; int out = 0, err = (e & NS_E_DEVICE) ? ns_dev_ioctl(d, req, arg, &out) : ns_obj_ioctl(d, e, req, arg); - ns_dev_put(d); if (err) { errno = err; *rc = -1; diff --git a/tools/tests/test_ntsync_preload.py b/tools/tests/test_ntsync_preload.py new file mode 100644 index 000000000..e05178c8d --- /dev/null +++ b/tools/tests/test_ntsync_preload.py @@ -0,0 +1,456 @@ +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +PRELOAD = Path(__file__).resolve().parents[1] / "linuxfs/preload" + +NTDLL = r""" +#define _GNU_SOURCE +#include +#include + +int install_handler(int sig, void (*fn)(int, siginfo_t *, void *), void **seen) { + struct sigaction sa, old; + memset(&sa, 0, sizeof(sa)); + sa.sa_sigaction = fn; + sa.sa_flags = SA_SIGINFO | SA_RESTART; + sigemptyset(&sa.sa_mask); + if (sigaction(sig, &sa, NULL) != 0) return -1; + if (sigaction(sig, NULL, &old) != 0) return -1; + *seen = (void *)old.sa_sigaction; + return 0; +} +""" + +PROGRAM = r""" +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +struct sem_args { uint32_t count, max; }; +struct mutex_args { uint32_t owner, count; }; +struct event_args { uint32_t manual, signaled; }; +struct wait_args { uint64_t timeout, objs; uint32_t count, index, flags, owner, alert, pad; }; +#define NT_CREATE_SEM _IOW('N', 0x80, struct sem_args) +#define NT_SEM_RELEASE _IOWR('N', 0x81, uint32_t) +#define NT_WAIT_ANY _IOWR('N', 0x82, struct wait_args) +#define NT_WAIT_ALL _IOWR('N', 0x83, struct wait_args) +#define NT_CREATE_MUTEX _IOW('N', 0x84, struct mutex_args) +#define NT_MUTEX_UNLOCK _IOWR('N', 0x85, struct mutex_args) +#define NT_MUTEX_KILL _IOW('N', 0x86, uint32_t) +#define NT_CREATE_EVENT _IOW('N', 0x87, struct event_args) +#define NT_EVENT_SET _IOR('N', 0x88, uint32_t) +#define NT_EVENT_RESET _IOR('N', 0x89, uint32_t) +#define NT_EVENT_PULSE _IOR('N', 0x8a, uint32_t) +#define NT_SEM_READ _IOR('N', 0x8b, struct sem_args) +#define NT_MUTEX_READ _IOR('N', 0x8c, struct mutex_args) +#define NT_EVENT_READ _IOR('N', 0x8d, struct event_args) + +int install_handler(int sig, void (*fn)(int, siginfo_t *, void *), void **seen); + +static int dev, fails; +#define CHECK(name, cond) do { if (!(cond)) { printf("FAIL %s line %d errno %d\n", name, __LINE__, errno); fails++; } } while (0) + +static uint64_t mono_ns(int64_t delta_ms) { + struct timespec t; + clock_gettime(CLOCK_MONOTONIC, &t); + return (uint64_t)t.tv_sec * 1000000000ull + (uint64_t)t.tv_nsec + (uint64_t)(delta_ms * 1000000); +} + +static int wait_on(unsigned long req, const int *objs, uint32_t count, uint32_t owner, int alert, uint64_t timeout, uint32_t *index) { + struct wait_args w = {timeout, (uintptr_t)objs, count, 0xdead, 0, owner, (uint32_t)alert, 0}; + int r = ioctl(dev, req, &w); + if (index) *index = w.index; + return r < 0 ? errno : 0; +} + +static int event(int manual, int signaled) { + struct event_args a = {(uint32_t)manual, (uint32_t)signaled}; + return ioctl(dev, NT_CREATE_EVENT, &a); +} + +static int sem(uint32_t count, uint32_t max) { + struct sem_args a = {count, max}; + return ioctl(dev, NT_CREATE_SEM, &a); +} + +static int mutex(uint32_t owner, uint32_t count) { + struct mutex_args a = {owner, count}; + return ioctl(dev, NT_CREATE_MUTEX, &a); +} + +static uint32_t prev_of(int fd, unsigned long req, uint32_t in) { + uint32_t v = in; + if (ioctl(fd, req, &v) < 0) return 0xffffffff; + return v; +} + +static void semaphores(void) { + struct sem_args bad = {3, 2}, r; + CHECK("sem_create_invalid", ioctl(dev, NT_CREATE_SEM, &bad) < 0 && errno == EINVAL); + int s = sem(2, 2); + CHECK("sem_create", s >= 0); + uint32_t idx; + CHECK("sem_wait1", wait_on(NT_WAIT_ANY, &s, 1, 1, 0, 0, &idx) == 0 && idx == 0); + CHECK("sem_wait2", wait_on(NT_WAIT_ANY, &s, 1, 1, 0, 0, &idx) == 0); + CHECK("sem_empty", wait_on(NT_WAIT_ANY, &s, 1, 1, 0, 0, NULL) == ETIMEDOUT); + CHECK("sem_release_prev", prev_of(s, NT_SEM_RELEASE, 1) == 0); + CHECK("sem_overflow", prev_of(s, NT_SEM_RELEASE, 2) == 0xffffffff && errno == EOVERFLOW); + CHECK("sem_read", ioctl(s, NT_SEM_READ, &r) == 0 && r.count == 1 && r.max == 2); + close(s); +} + +static void mutexes(void) { + struct mutex_args bad = {0, 1}, r, u; + CHECK("mutex_create_invalid", ioctl(dev, NT_CREATE_MUTEX, &bad) < 0 && errno == EINVAL); + int m = mutex(0, 0); + uint32_t idx; + CHECK("mutex_owner_zero", wait_on(NT_WAIT_ANY, &m, 1, 0, 0, 0, NULL) == EINVAL); + CHECK("mutex_take", wait_on(NT_WAIT_ANY, &m, 1, 7, 0, 0, &idx) == 0 && idx == 0); + CHECK("mutex_recurse", wait_on(NT_WAIT_ANY, &m, 1, 7, 0, 0, NULL) == 0); + CHECK("mutex_other", wait_on(NT_WAIT_ANY, &m, 1, 8, 0, 0, NULL) == ETIMEDOUT); + CHECK("mutex_read", ioctl(m, NT_MUTEX_READ, &r) == 0 && r.owner == 7 && r.count == 2); + u = (struct mutex_args){8, 0}; + CHECK("mutex_unlock_eperm", ioctl(m, NT_MUTEX_UNLOCK, &u) < 0 && errno == EPERM); + u = (struct mutex_args){0, 0}; + CHECK("mutex_unlock_einval", ioctl(m, NT_MUTEX_UNLOCK, &u) < 0 && errno == EINVAL); + u = (struct mutex_args){7, 0}; + CHECK("mutex_unlock_prev", ioctl(m, NT_MUTEX_UNLOCK, &u) == 0 && u.count == 2); + uint32_t who = 8; + CHECK("mutex_kill_eperm", ioctl(m, NT_MUTEX_KILL, &who) < 0 && errno == EPERM); + who = 7; + CHECK("mutex_kill", ioctl(m, NT_MUTEX_KILL, &who) == 0); + CHECK("mutex_read_dead", ioctl(m, NT_MUTEX_READ, &r) < 0 && errno == EOWNERDEAD && r.owner == 0 && r.count == 0); + CHECK("mutex_abandoned", wait_on(NT_WAIT_ANY, &m, 1, 9, 0, 0, &idx) == EOWNERDEAD && idx == 0); + CHECK("mutex_after_abandon", ioctl(m, NT_MUTEX_READ, &r) == 0 && r.owner == 9 && r.count == 1); + close(m); +} + +static void events(void) { + int a = event(0, 1), m = event(1, 0); + struct event_args r; + CHECK("auto_take", wait_on(NT_WAIT_ANY, &a, 1, 1, 0, 0, NULL) == 0); + CHECK("auto_consumed", wait_on(NT_WAIT_ANY, &a, 1, 1, 0, 0, NULL) == ETIMEDOUT); + CHECK("set_prev0", prev_of(m, NT_EVENT_SET, 0) == 0); + CHECK("set_prev1", prev_of(m, NT_EVENT_SET, 0) == 1); + CHECK("manual_kept", wait_on(NT_WAIT_ANY, &m, 1, 1, 0, 0, NULL) == 0 && wait_on(NT_WAIT_ANY, &m, 1, 1, 0, 0, NULL) == 0); + CHECK("reset_prev", prev_of(m, NT_EVENT_RESET, 0) == 1); + CHECK("pulse_prev", prev_of(m, NT_EVENT_PULSE, 0) == 0); + CHECK("pulse_leaves_unsignaled", ioctl(m, NT_EVENT_READ, &r) == 0 && r.manual == 1 && r.signaled == 0); + close(a); + close(m); +} + +static void any_order(void) { + int o[4] = {event(1, 0), sem(1, 5), event(1, 1), sem(0, 5)}; + int alert = event(0, 1); + uint32_t idx; + CHECK("any_lowest", wait_on(NT_WAIT_ANY, o, 4, 1, alert, 0, &idx) == 0 && idx == 1); + CHECK("any_next", wait_on(NT_WAIT_ANY, o, 4, 1, alert, 0, &idx) == 0 && idx == 2); + prev_of(o[2], NT_EVENT_RESET, 0); + CHECK("any_alert", wait_on(NT_WAIT_ANY, o, 4, 1, alert, 0, &idx) == 0 && idx == 4); + CHECK("any_alert_consumed", wait_on(NT_WAIT_ANY, o, 4, 1, alert, 0, NULL) == ETIMEDOUT); + int dup[3] = {o[3], o[0], o[3]}; + prev_of(o[3], NT_SEM_RELEASE, 1); + CHECK("any_duplicate_lowest", wait_on(NT_WAIT_ANY, dup, 3, 1, 0, 0, &idx) == 0 && idx == 0); + int many[65]; + for (int i = 0; i < 65; i++) many[i] = o[0]; + CHECK("any_too_many", wait_on(NT_WAIT_ANY, many, 65, 1, 0, 0, NULL) == EINVAL); + int badfd = 0; + CHECK("any_invalid_fd", wait_on(NT_WAIT_ANY, &badfd, 1, 1, 0, 0, NULL) == EINVAL); + CHECK("any_timeout", wait_on(NT_WAIT_ANY, o, 1, 1, 0, mono_ns(30), NULL) == ETIMEDOUT); + for (int i = 0; i < 4; i++) close(o[i]); + close(alert); +} + +static void all_semantics(void) { + int s = sem(1, 5), e = event(0, 0), m = mutex(0, 0); + int o[3] = {s, e, m}; + struct sem_args sr; + uint32_t idx; + CHECK("all_not_ready", wait_on(NT_WAIT_ALL, o, 3, 4, 0, 0, NULL) == ETIMEDOUT); + CHECK("all_untouched", ioctl(s, NT_SEM_READ, &sr) == 0 && sr.count == 1); + prev_of(e, NT_EVENT_SET, 0); + CHECK("all_ready", wait_on(NT_WAIT_ALL, o, 3, 4, 0, 0, &idx) == 0 && idx == 0); + CHECK("all_took_sem", ioctl(s, NT_SEM_READ, &sr) == 0 && sr.count == 0); + int dup[2] = {s, s}; + CHECK("all_duplicate", wait_on(NT_WAIT_ALL, dup, 2, 4, 0, 0, NULL) == EINVAL); + int alert = event(1, 1); + CHECK("all_alert", wait_on(NT_WAIT_ALL, o, 3, 4, alert, 0, &idx) == 0 && idx == 3); + close(s); + close(e); + close(m); + close(alert); +} + +static int wake_obj; +static uint32_t wake_index; +static int wake_ret; +static void *waiter(void *p) { + int all = (int)(intptr_t)p; + int objs[2] = {wake_obj, wake_obj + 0}; + wake_ret = wait_on(all ? NT_WAIT_ALL : NT_WAIT_ANY, objs, 1, 3, 0, UINT64_MAX, &wake_index); + return NULL; +} + +static void cross_thread(void) { + wake_obj = event(0, 0); + pthread_t t; + pthread_create(&t, NULL, waiter, NULL); + usleep(30000); + prev_of(wake_obj, NT_EVENT_SET, 0); + pthread_join(t, NULL); + CHECK("thread_wake", wake_ret == 0 && wake_index == 0); + struct event_args r; + CHECK("thread_auto_reset", ioctl(wake_obj, NT_EVENT_READ, &r) == 0 && r.signaled == 0); + close(wake_obj); + int s = sem(0, 10); + wake_obj = s; + pthread_t ts[3]; + for (int i = 0; i < 3; i++) pthread_create(&ts[i], NULL, waiter, NULL); + usleep(30000); + CHECK("sem_release_three", prev_of(s, NT_SEM_RELEASE, 3) == 0); + for (int i = 0; i < 3; i++) pthread_join(ts[i], NULL); + struct sem_args sr; + CHECK("sem_all_taken", ioctl(s, NT_SEM_READ, &sr) == 0 && sr.count == 0); + close(s); +} + +static void cross_process(void) { + int sv[2]; + CHECK("socketpair", socketpair(AF_UNIX, SOCK_STREAM, 0, sv) == 0); + int inherited = event(0, 0); + pid_t pid = fork(); + if (pid == 0) { + uint32_t idx; + int r1 = wait_on(NT_WAIT_ANY, &inherited, 1, 5, 0, mono_ns(5000), &idx); + char c; + struct iovec io = {&c, 1}; + char cbuf[CMSG_SPACE(sizeof(int))]; + struct msghdr msg = {0}; + msg.msg_iov = &io; + msg.msg_iovlen = 1; + msg.msg_control = cbuf; + msg.msg_controllen = sizeof(cbuf); + int got = -1; + if (recvmsg(sv[1], &msg, 0) == 1) memcpy(&got, CMSG_DATA(CMSG_FIRSTHDR(&msg)), sizeof(int)); + int r2 = wait_on(NT_WAIT_ANY, &got, 1, 5, 0, mono_ns(5000), NULL); + _exit(r1 == 0 && r2 == 0 ? 0 : 1); + } + usleep(30000); + prev_of(inherited, NT_EVENT_SET, 0); + int sent = sem(0, 1); + char c = 'x'; + struct iovec io = {&c, 1}; + char cbuf[CMSG_SPACE(sizeof(int))]; + struct msghdr msg = {0}; + msg.msg_iov = &io; + msg.msg_iovlen = 1; + msg.msg_control = cbuf; + msg.msg_controllen = sizeof(cbuf); + struct cmsghdr *cm = CMSG_FIRSTHDR(&msg); + cm->cmsg_level = SOL_SOCKET; + cm->cmsg_type = SCM_RIGHTS; + cm->cmsg_len = CMSG_LEN(sizeof(int)); + memcpy(CMSG_DATA(cm), &sent, sizeof(int)); + sendmsg(sv[0], &msg, 0); + usleep(30000); + prev_of(sent, NT_SEM_RELEASE, 1); + int status = 0; + waitpid(pid, &status, 0); + CHECK("process_wake", WIFEXITED(status) && WEXITSTATUS(status) == 0); + close(inherited); + close(sent); + close(sv[0]); + close(sv[1]); +} + +static int ping_obj, pong_obj, stress_sem; +static void *stress_ponger(void *p) { + long n = (long)p; + uint32_t v; + for (long i = 0; i < n; i++) { + if (wait_on(NT_WAIT_ANY, &ping_obj, 1, 21, 0, UINT64_MAX, NULL) != 0) return (void *)1; + ioctl(pong_obj, NT_EVENT_SET, &v); + } + return NULL; +} +static void *stress_hammer(void *p) { + long n = (long)p; + for (long i = 0; i < n; i++) { + if (wait_on(NT_WAIT_ANY, &stress_sem, 1, 22, 0, UINT64_MAX, NULL) != 0) return (void *)1; + uint32_t c = 1; + ioctl(stress_sem, NT_SEM_RELEASE, &c); + } + return NULL; +} + +static void stress(void) { + long rounds = 20000; + ping_obj = event(0, 0); + pong_obj = event(0, 0); + pthread_t t; + void *bad = NULL; + pthread_create(&t, NULL, stress_ponger, (void *)rounds); + uint32_t v; + int ok = 1; + for (long i = 0; i < rounds && ok; i++) { + ioctl(ping_obj, NT_EVENT_SET, &v); + ok = wait_on(NT_WAIT_ANY, &pong_obj, 1, 20, 0, UINT64_MAX, NULL) == 0; + } + pthread_join(t, &bad); + CHECK("stress_pingpong", ok && bad == NULL); + stress_sem = sem(1, 1); + pthread_t th[4]; + for (int i = 0; i < 4; i++) pthread_create(&th[i], NULL, stress_hammer, (void *)5000L); + int hammer_ok = 1; + for (int i = 0; i < 4; i++) { + pthread_join(th[i], &bad); + hammer_ok &= bad == NULL; + } + struct sem_args sr; + CHECK("stress_contended", hammer_ok && ioctl(stress_sem, NT_SEM_READ, &sr) == 0 && sr.count == 1); + close(ping_obj); + close(pong_obj); + close(stress_sem); +} + +static int busy_obj; +static volatile int handled, stop_worker; +static sem_t parked, unpark; +static void reentrant(int sig, siginfo_t *si, void *uc) { + (void)sig; (void)si; (void)uc; + uint32_t v = 0; + ioctl(busy_obj, NT_EVENT_SET, &v); + handled++; +} +static void suspending(int sig, siginfo_t *si, void *uc) { + (void)sig; (void)si; (void)uc; + sem_post(&parked); + while (sem_wait(&unpark) != 0) {} + handled++; +} +static void *worker(void *p) { + (void)p; + uint32_t v; + while (!stop_worker) { + ioctl(busy_obj, NT_EVENT_SET, &v); + wait_on(NT_WAIT_ANY, &busy_obj, 1, 11, 0, 0, NULL); + } + return NULL; +} + +static void signals(void) { + void *seen = NULL; + busy_obj = event(0, 0); + CHECK("install_reentrant", install_handler(SIGUSR1, reentrant, &seen) == 0); + CHECK("query_original", seen == (void *)reentrant); + pthread_t w; + stop_worker = 0; + handled = 0; + pthread_create(&w, NULL, worker, NULL); + for (int i = 0; i < 2000; i++) { + pthread_kill(w, SIGUSR1); + if (i % 64 == 0) usleep(100); + } + usleep(20000); + stop_worker = 1; + pthread_join(w, NULL); + CHECK("reentrant_ran", handled > 0); + sem_init(&parked, 0, 0); + sem_init(&unpark, 0, 0); + CHECK("install_suspending", install_handler(SIGUSR1, suspending, &seen) == 0); + stop_worker = 0; + handled = 0; + pthread_create(&w, NULL, worker, NULL); + uint32_t v; + for (int i = 0; i < 300; i++) { + pthread_kill(w, SIGUSR1); + while (sem_wait(&parked) != 0) {} + ioctl(busy_obj, NT_EVENT_SET, &v); + wait_on(NT_WAIT_ANY, &busy_obj, 1, 12, 0, 0, NULL); + sem_post(&unpark); + } + stop_worker = 1; + pthread_join(w, NULL); + CHECK("suspend_ran", handled == 300); + close(busy_obj); +} + +int main(void) { + alarm(60); + dev = open("/dev/ntsync", O_RDWR | O_CLOEXEC); + if (dev < 0) { + printf("no device %d\n", errno); + return 1; + } + semaphores(); + mutexes(); + events(); + any_order(); + all_semantics(); + cross_thread(); + cross_process(); + stress(); + signals(); + printf("done fails=%d\n", fails); + return fails != 0; +} +""" + + +class NtsyncPreloadTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + if not shutil.which("cc"): + raise unittest.SkipTest("no compiler") + cls.tmp = tempfile.TemporaryDirectory() + tmp = Path(cls.tmp.name) + (tmp / "ntdll.c").write_text(NTDLL) + (tmp / "prog.c").write_text(PROGRAM) + sources = sorted(str(p) for p in PRELOAD.glob("*.c")) + subprocess.run(["cc", "-shared", "-fPIC", "-O2", "-Wall", "-pthread", "-o", str(tmp / "libblsession.so"), *sources, "-ldl"], + check=True) + subprocess.run(["cc", "-shared", "-fPIC", "-O2", "-o", str(tmp / "ntdll.so"), str(tmp / "ntdll.c")], check=True) + subprocess.run(["cc", "-O1", "-pthread", "-o", str(tmp / "prog"), str(tmp / "prog.c"), str(tmp / "ntdll.so"), + "-Wl,-rpath," + str(tmp)], check=True) + cls.program = tmp / "prog" + cls.preload = tmp / "libblsession.so" + + @classmethod + def tearDownClass(cls): + cls.tmp.cleanup() + + def run_program(self): + env = dict(os.environ, LD_PRELOAD=str(self.preload), BL_SYNC="1") + return subprocess.run([str(self.program)], env=env, capture_output=True, text=True, timeout=90) + + def test_emulation_matches_the_ntsync_driver(self): + out = self.run_program() + self.assertEqual(out.returncode, 0, out.stdout + out.stderr) + self.assertIn("done fails=0", out.stdout) + + def test_repeated_runs_stay_consistent(self): + for _ in range(3): + out = self.run_program() + self.assertEqual(out.returncode, 0, out.stdout + out.stderr) + + +if __name__ == "__main__": + unittest.main() From f7acb7fcd7b0dad2ccbaccf39e8aed288b38fa71 Mon Sep 17 00:00:00 2001 From: FX192 <120828496+FX192@users.noreply.github.com> Date: Fri, 23 Oct 2026 04:50:32 +0300 Subject: [PATCH 10/59] Inner Gamer RGB theme with a speed slider; keep the colour picker's hex box in view above the keyboard The hex box sat at the bottom of the picker, so with the keyboard up the dialog was cut off and the field could not be seen. While the box has focus the dialog now scrolls to its end, and again whenever the visible height changes, so the hex box and OK / Cancel stay above the keyboard. Inner Gamer is a new theme just above Gradient. Its accent walks round the colour wheel on a Compose infinite transition, so Android's animation clock drives it and it holds still when animations are off. The hue moves in 3 degree steps so the app restyles only when the colour visibly changes. Setup shows a Speed slider (1 to 10) while it is selected. The palette comes from ThemeColors.palette, so button text stays readable at every hue. --- .../launcher/session/SessionPrefs.kt | 8 +++ .../com/droiddeck/launcher/ui/AddAppDialog.kt | 3 +- .../launcher/ui/ColorPickerDialog.kt | 7 ++- .../droiddeck/launcher/ui/FrontEndSetup.kt | 8 +++ .../java/com/droiddeck/launcher/ui/Theme.kt | 55 +++++++++++++++++++ .../com/droiddeck/launcher/ui/ThemeColors.kt | 4 +- app/src/main/res/values-ar/strings.xml | 4 ++ app/src/main/res/values-b+es+419/strings.xml | 4 ++ app/src/main/res/values-bg/strings.xml | 4 ++ app/src/main/res/values-cs/strings.xml | 4 ++ app/src/main/res/values-da/strings.xml | 4 ++ app/src/main/res/values-de/strings.xml | 4 ++ app/src/main/res/values-el/strings.xml | 4 ++ app/src/main/res/values-es/strings.xml | 4 ++ app/src/main/res/values-fi/strings.xml | 4 ++ app/src/main/res/values-fr/strings.xml | 4 ++ app/src/main/res/values-hi/strings.xml | 4 ++ app/src/main/res/values-hu/strings.xml | 4 ++ app/src/main/res/values-in/strings.xml | 4 ++ app/src/main/res/values-it/strings.xml | 4 ++ app/src/main/res/values-ja/strings.xml | 4 ++ app/src/main/res/values-ko/strings.xml | 4 ++ app/src/main/res/values-ms/strings.xml | 4 ++ app/src/main/res/values-nb/strings.xml | 4 ++ app/src/main/res/values-nl/strings.xml | 4 ++ app/src/main/res/values-pl/strings.xml | 4 ++ app/src/main/res/values-pt-rPT/strings.xml | 4 ++ app/src/main/res/values-pt/strings.xml | 4 ++ app/src/main/res/values-ro/strings.xml | 4 ++ app/src/main/res/values-ru/strings.xml | 4 ++ app/src/main/res/values-sv/strings.xml | 4 ++ app/src/main/res/values-th/strings.xml | 4 ++ app/src/main/res/values-tr/strings.xml | 4 ++ app/src/main/res/values-uk/strings.xml | 4 ++ app/src/main/res/values-vi/strings.xml | 4 ++ app/src/main/res/values-zh-rCN/strings.xml | 4 ++ app/src/main/res/values-zh-rHK/strings.xml | 4 ++ app/src/main/res/values-zh-rTW/strings.xml | 4 ++ app/src/main/res/values/strings.xml | 4 ++ .../droiddeck/launcher/ui/ThemeColorsTest.kt | 36 ++++++++++++ docs/development/themes.md | 14 +++++ 41 files changed, 263 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt index d9d2d8e1d..f28c68734 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -764,6 +764,14 @@ object SessionPrefs { it.getInt("gradientSecondary", com.droiddeck.launcher.ui.Themes.DEFAULT_SECONDARY) } + /** How fast the Inner Gamer theme cycles its colours, 1 to 10. */ + fun rgbSpeed(context: Context): Int = + prefs(context).getInt("innerGamerSpeed", com.droiddeck.launcher.ui.Themes.DEFAULT_RGB_SPEED).coerceIn(1, com.droiddeck.launcher.ui.Themes.RGB_SPEEDS) + + fun setRgbSpeed(context: Context, speed: Int) { + prefs(context).edit().putInt("innerGamerSpeed", speed.coerceIn(1, com.droiddeck.launcher.ui.Themes.RGB_SPEEDS)).apply() + } + fun setGradientColors(context: Context, primary: Int, secondary: Int) { prefs(context).edit().putInt("gradientPrimary", primary).putInt("gradientSecondary", secondary).apply() } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/AddAppDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/AddAppDialog.kt index 5e63d7b0f..b3b88f0aa 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/AddAppDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/AddAppDialog.kt @@ -308,6 +308,7 @@ internal fun AppDialog( width: Dp? = null, fitContent: Boolean = false, back: (() -> Unit)? = null, scrollable: Boolean = true, + scrollState: androidx.compose.foundation.ScrollState = rememberScrollState(), content: @Composable ColumnScope.() -> Unit, ) { val pal = LocalPalette.current @@ -345,7 +346,7 @@ internal fun AppDialog( .blockTouches() .controllerBack(back ?: close) .then(modifier) - .then(if (scrollable) Modifier.verticalScroll(rememberScrollState()) else Modifier) + .then(if (scrollable) Modifier.verticalScroll(scrollState) else Modifier) .padding(horizontal = 18.dp, vertical = 16.dp), content = content, ) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ColorPickerDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/ColorPickerDialog.kt index e94f193e9..d83e1abe1 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ColorPickerDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ColorPickerDialog.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable @@ -70,12 +71,15 @@ internal fun ColorPickerDialog(title: String, initial: Int, onDismiss: () -> Uni var hexText by remember { mutableStateOf(ThemeColors.hex(initial)) } val current = ThemeColors.fromHsv(hue, sat, value) val first = remember { FocusRequester() } + val scroll = rememberScrollState() + var typingHex by remember { mutableStateOf(false) } LaunchedEffect(Unit) { focusSoon { first } } + LaunchedEffect(typingHex, scroll.maxValue) { if (typingHex) scroll.animateScrollTo(scroll.maxValue) } fun picked(h: Float = hue, s: Float = sat, v: Float = value) { hue = h.coerceIn(0f, 360f); sat = s.coerceIn(0f, 1f); value = v.coerceIn(0f, 1f) hexText = ThemeColors.hex(ThemeColors.fromHsv(hue, sat, value)) } - AppDialog(shown, close, "colorPicker", wide = false, maxWidth = 460.dp) { + AppDialog(shown, close, "colorPicker", wide = false, maxWidth = 460.dp, scrollState = scroll) { DialogTitleRow(title, close) Row(horizontalArrangement = Arrangement.spacedBy(14.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { ColorWheel(hue, sat, value) { h, s -> picked(h = h, s = s) } @@ -95,6 +99,7 @@ internal fun ColorPickerDialog(title: String, initial: Int, onDismiss: () -> Uni ThemeColors.parseHex(text)?.let { argb -> ThemeColors.toHsv(argb).let { hsv -> hue = hsv.h; sat = hsv.s; value = hsv.v } } }, label = "", keyboardType = KeyboardType.Ascii, imeAction = ImeAction.Done, placeholder = stringResource(R.string.theme_color_hex), + onFocusChange = { typingHex = it }, onDone = { ThemeColors.parseHex(hexText)?.let { onApply(it); close() } }, ) Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt index b5bbaa2e9..c2582837f 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt @@ -359,6 +359,7 @@ internal fun SetupPanel( } } } + if (s.theme == Themes.INNER_GAMER) RgbSpeedRow() if (s.theme == Themes.GRADIENT) GradientColorRows() ChoiceRow( host, "app-scale", stringResource(R.string.setup_app_scale), stringResource(R.string.setup_app_scale_hint), @@ -792,6 +793,13 @@ private fun LanguageRow(host: MenuHost, chosen: String, onPick: (String) -> Unit } } +/** How fast Inner Gamer cycles its colours. */ +@Composable +private fun RgbSpeedRow() { + val ctx = androidx.compose.ui.platform.LocalContext.current.applicationContext + SliderRow(stringResource(R.string.theme_rgb_speed), stringResource(R.string.theme_rgb_speed_hint), Themes.rgbSpeed, 1..Themes.RGB_SPEEDS) { Themes.setRgbSpeed(ctx, it) } +} + /** The two colours of the Gradient theme, each a small square that opens the colour picker. */ @Composable private fun GradientColorRows() { diff --git a/app/src/main/java/com/droiddeck/launcher/ui/Theme.kt b/app/src/main/java/com/droiddeck/launcher/ui/Theme.kt index 0f3843fca..4eae384e2 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/Theme.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/Theme.kt @@ -3,7 +3,14 @@ package com.droiddeck.launcher.ui import androidx.annotation.StringRes import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.getValue @@ -13,6 +20,7 @@ import android.content.Context import androidx.compose.runtime.remember import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext import com.droiddeck.launcher.R import androidx.compose.ui.platform.LocalConfiguration @@ -33,6 +41,7 @@ object Themes { const val PAPER = "paper" const val ICON_BLUE = "blue" const val ELECTRIC = "electric" + const val INNER_GAMER = "inner_gamer" const val GRADIENT = "gradient" const val DEFAULT_PRIMARY = 0xFF8B5CF6.toInt() @@ -42,10 +51,42 @@ object Themes { var gradientColors by mutableStateOf(DEFAULT_PRIMARY to DEFAULT_SECONDARY) private set + const val RGB_SPEEDS = 10 + const val DEFAULT_RGB_SPEED = 3 + const val RGB_HUE_STEP = 3f + + /** How fast Inner Gamer cycles, 1 (slow) to [RGB_SPEEDS] (fast). */ + var rgbSpeed by mutableStateOf(DEFAULT_RGB_SPEED) + private set + + /** Where Inner Gamer is on the colour wheel, in whole [RGB_HUE_STEP] steps so the app restyles only when the colour visibly moves. */ + var rgbHue by mutableStateOf(0f) + private set + + fun setRgbHue(hue: Float) { + rgbHue = hue + } + + fun setRgbSpeed(context: Context, speed: Int) { + val value = speed.coerceIn(1, RGB_SPEEDS) + SessionPrefs.setRgbSpeed(context, value) + rgbSpeed = value + } + + /** Milliseconds for one trip round the colour wheel. */ + fun rgbCycleMillis(speed: Int): Int = 30_000 / speed.coerceIn(1, RGB_SPEEDS) + fun loadGradient(context: Context) { gradientColors = SessionPrefs.gradientColors(context) + rgbSpeed = SessionPrefs.rgbSpeed(context) } + /** The Inner Gamer palette at one point on the colour wheel. */ + fun innerGamer(hue: Float): Palette = ThemeColors.palette( + Color.hsv(hue % 360f, 0.9f, 1f).toArgb(), Color.hsv((hue + 60f) % 360f, 0.9f, 1f).toArgb(), + R.string.theme_inner_gamer, R.string.theme_inner_gamer_detail, INNER_GAMER, + ) + fun setGradient(context: Context, primary: Int, secondary: Int) { SessionPrefs.setGradientColors(context, primary, secondary) gradientColors = primary to secondary @@ -87,10 +128,12 @@ object Themes { primary = Color(0xFF2E86FF), primary2 = Color(0xFF2684FF), onPrimary = Color(0xFF03111F), signal = Color(0xFF2E86FF), good = Color(0xFF4CD37F), ), + innerGamer(0f), ThemeColors.palette(DEFAULT_PRIMARY, DEFAULT_SECONDARY, R.string.theme_gradient, R.string.theme_gradient_detail), ) fun byId(id: String): Palette { + if (id == INNER_GAMER) return innerGamer(rgbHue) if (id == GRADIENT) return ThemeColors.palette(gradientColors.first, gradientColors.second, R.string.theme_gradient, R.string.theme_gradient_detail) return all.firstOrNull { it.id == id } ?: all[0] } @@ -103,6 +146,7 @@ val Palette.onSignal: Color get() = if (signal.luminance() > 0.18f) Color(0xFF03 @Composable fun DroidDeckTheme(theme: String? = null, appScale: Int = AppUiPrefs.DEFAULT_SCALE, content: @Composable () -> Unit) { val id = theme ?: SessionPrefs.theme(LocalContext.current) + if (id == Themes.INNER_GAMER) InnerGamerHue(Themes.rgbSpeed) val p = Themes.byId(id) val scheme = darkColorScheme( primary = p.primary, onPrimary = p.onPrimary, secondary = p.onSurfaceVariant, @@ -121,3 +165,14 @@ fun DroidDeckTheme(theme: String? = null, appScale: Int = AppUiPrefs.DEFAULT_SCA LocalConfiguration provides configuration, ) { MaterialTheme(colorScheme = scheme, content = content) } } + +/** Turns the colour wheel on the system's own animation clock; with animations off, in the app or in Android, the colour holds still. */ +@Composable +private fun InnerGamerHue(speed: Int) { + if (Motion.scale == 0f) return + val hue by rememberInfiniteTransition(label = "innerGamer").animateFloat( + 0f, 360f, infiniteRepeatable(tween(Motion.ms(Themes.rgbCycleMillis(speed)).coerceAtLeast(1), easing = LinearEasing), RepeatMode.Restart), label = "hue", + ) + val step = Themes.RGB_HUE_STEP + SideEffect { Themes.setRgbHue((hue / step).toInt() * step) } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ThemeColors.kt b/app/src/main/java/com/droiddeck/launcher/ui/ThemeColors.kt index b4b57737e..b2db63562 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ThemeColors.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ThemeColors.kt @@ -132,7 +132,7 @@ internal object ThemeColors { } } - fun palette(primary: Int, secondary: Int, label: Int, detail: Int): Palette { + fun palette(primary: Int, secondary: Int, label: Int, detail: Int, id: String = Themes.GRADIENT): Palette { val seen = readable(primary) val seenSecond = readable(secondary) val ink = inkFor(seen, seenSecond) @@ -146,7 +146,7 @@ internal object ThemeColors { second = nextSecond } return Palette( - Themes.GRADIENT, label, detail, + id, label, detail, background = tint(first, 0.50f, 0.045f), surface = tint(first, 0.45f, 0.075f), surfaceVariant = tint(first, 0.42f, 0.115f), line = tint(first, 0.40f, 0.16f), line2 = tint(first, 0.36f, 0.22f), onBackground = tint(first, 0.05f, 0.97f), onSurfaceVariant = tint(first, 0.16f, 0.68f), diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index c7528035f..c3d790b84 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -1457,6 +1457,10 @@ خلفية سوداء ونص أبيض، والأزرق لكل عنصر محدَّد أو مُركَّز عليه أو مضغوط. كحلي كهربائي أزرق أكثر سطوعًا، على أسطح تميل إلى الكحلي بدلاً من الرمادي. + Inner Gamer + ألوان تتدرّج عبر ألوان قوس قزح. تتبع إعدادات الرسوم المتحركة في Android. + السرعة + مدى سرعة تغيّر الألوان. تدرج لوني لونك الأساسي والثانوي، ممزوجان على الأزرار وعناصر التمييز. الأساسي diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index d6a7aa64e..0464089bd 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -1466,6 +1466,10 @@ Fondo negro, texto blanco y el azul para todo lo seleccionado, enfocado o presionado. Azul marino eléctrico Un azul más vivo, sobre superficies que tiran a azul marino en vez de gris. + Inner Gamer + Colores que recorren el arcoíris. Respeta los ajustes de animación de Android. + Velocidad + Qué tan rápido cambian los colores. Degradado Tus propios colores primario y secundario, combinados en botones y resaltados. Primario diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index e5ec507d7..97b824670 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -1401,6 +1401,10 @@ Черен фон, бял текст, синьото за всичко избрано, на фокус или натиснато. Яркосиньо По-яркото синьо, върху повърхности, по-близки до тъмносиньо, вместо сиво. + Inner Gamer + Цветове, които преминават през дъгата. Следва настройките за анимации на Android. + Скорост + Колко бързо се сменят цветовете. Градиент Вашите основен и вторичен цвят, смесени в бутоните и акцентите. Основен diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 7ff26b087..f8f659b8a 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -1429,6 +1429,10 @@ Černé pozadí, bílé písmo, modrá pro vše vybrané, zaměřené nebo stisknuté. Elektrická modř Jasnější modrá na povrchách, které jsou spíše tmavomodré než šedé. + Inner Gamer + Barvy, které procházejí celou duhu. Respektuje nastavení animací v Androidu. + Rychlost + Jak rychle se barvy střídají. Přechod Vaše vlastní primární a sekundární barva, prolnuté na tlačítkách a zvýrazněních. Primární diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index dbe459497..a2686e54e 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -1401,6 +1401,10 @@ Sort baggrund, hvid skrift, blå til alt, der er valgt, fokuseret eller trykket. Elektrisk marineblå Den blå er klarere, på overflader der trækker mod marineblåt i stedet for gråt. + Inner Gamer + Farver, der skifter gennem regnbuen. Følger Androids animationsindstillinger. + Hastighed + Hvor hurtigt farverne skifter. Gradient Dine egne primære og sekundære farver, blandet på knapper og fremhævninger. Primær diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 73fa6ae85..e68671239 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1393,6 +1393,10 @@ Schwarzer Grund, weiße Schrift, das Blau für alles Ausgewählte, Fokussierte oder Gedrückte. Elektrisches Marineblau Das Blau heller, auf Flächen, die eher zu Marineblau als zu Grau tendieren. + Inner Gamer + Farben, die durch den Regenbogen wechseln. Folgt den Animationseinstellungen von Android. + Geschwindigkeit + Wie schnell die Farben wechseln. Farbverlauf Eigene Primär- und Sekundärfarbe, als Verlauf auf Schaltflächen und Hervorhebungen. Primär diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 097d18c2b..0667040d4 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -1401,6 +1401,10 @@ Μαύρο φόντο, λευκά γράμματα, το μπλε για ό,τι είναι επιλεγμένο, εστιασμένο ή πατημένο. Ηλεκτρικό ναυτικό μπλε Το μπλε πιο έντονο, σε επιφάνειες που τείνουν προς το ναυτικό μπλε αντί για γκρι. + Inner Gamer + Χρώματα που διατρέχουν το ουράνιο τόξο. Ακολουθεί τις ρυθμίσεις animation του Android. + Ταχύτητα + Πόσο γρήγορα αλλάζουν τα χρώματα. Διαβάθμιση Δικά σας πρωτεύον και δευτερεύον χρώμα, σε μίξη στα κουμπιά και τις επισημάνσεις. Πρωτεύον diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index edbccd44d..57b09bf6b 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1417,6 +1417,10 @@ Fondo negro, texto blanco y el azul para todo lo seleccionado, enfocado o pulsado. Azul marino eléctrico Un azul más vivo, sobre superficies que tiran a azul marino en vez de gris. + Inner Gamer + Colores que recorren el arcoíris. Respeta los ajustes de animación de Android. + Velocidad + Con qué rapidez cambian los colores. Degradado Tus propios colores primario y secundario, combinados en botones y resaltados. Primario diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index 2d8d8a12b..2b462ab88 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -1401,6 +1401,10 @@ Musta pohja, valkoinen teksti, sininen kaikessa valitussa, kohdistetussa tai painetussa. Sähköinen laivastonsininen Sininen kirkkaampana pinnoilla, jotka taittuvat harmaan sijaan laivastonsiniseen. + Inner Gamer + Värit, jotka kiertävät sateenkaaren läpi. Noudattaa Androidin animaatioasetuksia. + Nopeus + Kuinka nopeasti värit vaihtuvat. Liukuväri Omat ensisijainen ja toissijainen värisi, sulautettuina painikkeisiin ja korostuksiin. Ensisijainen diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index b268e9efd..54342beae 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -1417,6 +1417,10 @@ Fond noir, texte blanc, et le bleu pour tout élément sélectionné, ciblé ou pressé. Marine électrique Un bleu plus vif, sur des surfaces qui tirent vers le marine plutôt que vers le gris. + Inner Gamer + Des couleurs qui parcourent l\'arc-en-ciel. Respecte les réglages d\'animation d\'Android. + Vitesse + La vitesse de défilement des couleurs. Dégradé Vos propres couleurs primaire et secondaire, fondues sur les boutons et les éléments en surbrillance. Primaire diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index d08626be6..5113e7764 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -1401,6 +1401,10 @@ काली ज़मीन, सफ़ेद टाइप, चुने, फ़ोकस या दबाए गए हर चीज़ के लिए ब्लू रंग। इलेक्ट्रिक नेवी ब्लू रंग ज़्यादा चमकीला, उन सतहों पर जो ग्रे के बजाय नेवी की ओर झुकती हैं। + Inner Gamer + इंद्रधनुष के रंगों में बदलते रंग। Android की एनिमेशन सेटिंग का पालन करता है। + गति + रंग कितनी तेज़ी से बदलें। ग्रेडिएंट आपके अपने प्राथमिक और द्वितीयक रंग, बटनों और हाइलाइट पर मिश्रित। प्राथमिक diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 6be57ffd2..5e26a3d04 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -1401,6 +1401,10 @@ Fekete alap, fehér betűk, a kék minden kijelölt, fókuszált vagy lenyomott elemhez. Elektromos tengerészkék Fényesebb kék, szürke helyett tengerészkék felületeken. + Inner Gamer + A szivárvány színein végigfutó színek. Követi az Android animációs beállításait. + Sebesség + Milyen gyorsan váltanak a színek. Színátmenet Saját elsődleges és másodlagos színed, átmenettel a gombokon és a kiemeléseken. Elsődleges diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 776885a84..89e8a72da 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -1387,6 +1387,10 @@ Dasar hitam, tipe putih, biru untuk semua yang dipilih, difokuskan, atau ditekan. Navy elektrik Biru yang lebih cerah, pada permukaan yang condong ke navy, bukan abu-abu. + Inner Gamer + Warna yang berganti menyusuri pelangi. Mengikuti pengaturan animasi Android. + Kecepatan + Seberapa cepat warna berganti. Gradasi Warna primer dan sekunder pilihan Anda, dipadukan pada tombol dan sorotan. Primer diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index d5fde44e5..38bafda84 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1407,6 +1407,10 @@ Fondo nero, testo bianco, il blu per tutto ciò che è selezionato, attivo o premuto. Blu elettrico Il blu più brillante, su superfici che tendono al blu navy invece che al grigio. + Inner Gamer + Colori che scorrono lungo l\'arcobaleno. Rispetta le impostazioni di animazione di Android. + Velocità + Quanto velocemente cambiano i colori. Gradiente I tuoi colori primario e secondario, sfumati su pulsanti ed evidenziazioni. Primario diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 962a16946..18db0ce6f 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -1389,6 +1389,10 @@ 黒の背景に白の文字、選択・フォーカス・押下時には青を使用します。 エレクトリックネイビー より明るい青を、グレーではなくネイビー寄りの画面に使用します。 + Inner Gamer + 虹色に変化し続けるカラー。Android のアニメーション設定に従います。 + 速度 + 色が切り替わる速さ。 グラデーション 自分で選んだプライマリとセカンダリの色を、ボタンやハイライトにグラデーションで使います。 プライマリ diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 7d58f7a91..70e71aeba 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -1389,6 +1389,10 @@ 검은색 배경에 흰색 글자, 선택·포커스·누름 상태에는 파란색을 사용합니다. 일렉트릭 네이비 회색 대신 네이비 톤의 화면에 더 밝은 파란색을 사용합니다. + Inner Gamer + 무지개 색으로 계속 바뀌는 색상입니다. Android의 애니메이션 설정을 따릅니다. + 속도 + 색상이 바뀌는 속도입니다. 그라데이션 직접 고른 기본색과 보조색이 버튼과 강조 표시에 섞여 적용됩니다. 기본색 diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index 3309b0ce8..37ec6b6cb 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -1387,6 +1387,10 @@ Latar hitam, teks putih, biru untuk semua yang dipilih, difokus atau ditekan. Navy elektrik Biru lebih cerah, pada permukaan yang lebih cenderung navy daripada kelabu. + Inner Gamer + Warna yang bertukar merentasi pelangi. Mengikut tetapan animasi Android. + Kelajuan + Seberapa pantas warna bertukar. Kecerunan Warna utama dan sekunder pilihan anda, diadun pada butang dan sorotan. Utama diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml index 67ba109eb..d0ba1beaf 100644 --- a/app/src/main/res/values-nb/strings.xml +++ b/app/src/main/res/values-nb/strings.xml @@ -1401,6 +1401,10 @@ Svart bakgrunn, hvit skrift, det blå for alt som er valgt, fokusert eller trykket. Elektrisk marineblå Det blå lysere, på flater som lener mer mot marineblått enn grått. + Inner Gamer + Farger som går gjennom regnbuen. Følger Androids animasjonsinnstillinger. + Hastighet + Hvor raskt fargene skifter. Gradient Dine egne primær- og sekundærfarger, blandet på knapper og uthevinger. Primær diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 183254ed4..a004d04e6 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -1401,6 +1401,10 @@ Zwarte achtergrond, witte tekst, het blauw voor alles wat geselecteerd, gefocust of ingedrukt is. Elektrisch marineblauw Het blauw feller, op vlakken die naar marineblauw neigen in plaats van grijs. + Inner Gamer + Kleuren die door de regenboog lopen. Volgt de animatie-instellingen van Android. + Snelheid + Hoe snel de kleuren wisselen. Kleurverloop Je eigen primaire en secundaire kleur, doorlopend op knoppen en accenten. Primair diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 92c261d45..776ec2150 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -1429,6 +1429,10 @@ Czarne tło, biały tekst i niebieski dla wszystkiego, co zaznaczone, aktywne lub naciśnięte. Elektryczny granat Jaśniejszy niebieski na powierzchniach wpadających w granat zamiast w szarość. + Inner Gamer + Kolory przechodzące przez całą tęczę. Szanuje ustawienia animacji Androida. + Prędkość + Jak szybko zmieniają się kolory. Gradient Własny kolor podstawowy i dodatkowy, płynnie przechodzące na przyciskach i wyróżnieniach. Podstawowy diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index c5dc17cb0..5c0b03809 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -1407,6 +1407,10 @@ Fundo preto, texto branco e o azul para tudo o que está selecionado, focado ou premido. Azul-marinho elétrico O azul mais vivo, em superfícies que tendem para o azul-marinho em vez do cinzento. + Inner Gamer + Cores que percorrem o arco-íris. Respeita as definições de animação do Android. + Velocidade + Com que rapidez as cores mudam. Gradiente As suas próprias cores primária e secundária, combinadas nos botões e destaques. Primária diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index a94b36b00..1af04d01d 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -1407,6 +1407,10 @@ Fundo preto, texto branco e o azul em tudo que estiver selecionado, em foco ou pressionado. Marinho elétrico O azul mais vivo, sobre superfícies puxadas para o marinho em vez do cinza. + Inner Gamer + Cores que percorrem o arco-íris. Respeita as configurações de animação do Android. + Velocidade + Com que rapidez as cores mudam. Gradiente Suas próprias cores primária e secundária, combinadas em botões e destaques. Primária diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 1fdb69d8e..5295bc5b9 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -1415,6 +1415,10 @@ Fundal negru, text alb, albastrul pentru tot ce este selectat, focalizat sau apăsat. Bleumarin electric Albastrul mai strălucitor, pe suprafețe care tind spre bleumarin în loc de gri. + Inner Gamer + Culori care parcurg curcubeul. Respectă setările de animație din Android. + Viteză + Cât de repede se schimbă culorile. Gradient Culorile tale primară și secundară, combinate pe butoane și evidențieri. Primară diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index c782aae6b..f03db3687 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1431,6 +1431,10 @@ Черный фон, белый текст, синий — для всего выбранного, активного или нажатого. Электрик Более яркий синий на поверхностях темно-синего, а не серого оттенка. + Inner Gamer + Цвета, плавно переходящие по всей радуге. Учитывает настройки анимации Android. + Скорость + Как быстро меняются цвета. Градиент Ваши основной и дополнительный цвета, плавно переходящие на кнопках и выделениях. Основной diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index c6659abfd..4f50f51a0 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -1401,6 +1401,10 @@ Svart bakgrund, vit text, den blå färgen för allt som är markerat, i fokus eller nedtryckt. Elektrisk marinblå Den blå färgen klarare, på ytor som lutar åt marinblått i stället för grått. + Inner Gamer + Färger som växlar genom regnbågen. Följer Androids animationsinställningar. + Hastighet + Hur snabbt färgerna växlar. Gradient Dina egna primära och sekundära färger, blandade på knappar och markeringar. Primär diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index 7dd606217..f486774a5 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -1387,6 +1387,10 @@ พื้นดำ ตัวอักษรขาว สีฟ้าสำหรับทุกสิ่งที่ถูกเลือก โฟกัส หรือกด กรมท่าไฟฟ้า สีฟ้าสว่างขึ้น บนพื้นผิวที่เอียงไปทางกรมท่ามากกว่าเทา + Inner Gamer + สีที่เปลี่ยนวนไปตามสายรุ้ง ทำงานตามการตั้งค่าแอนิเมชันของ Android + ความเร็ว + สีเปลี่ยนเร็วแค่ไหน ไล่เฉดสี สีหลักและสีรองที่คุณเลือกเอง ผสมกันบนปุ่มและส่วนที่เน้น สีหลัก diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 5422b926c..c112dd6b6 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -1395,6 +1395,10 @@ Siyah zemin, beyaz yazı; seçilen, odaklanan veya basılan her şey için mavi. Elektrik laciverti Daha parlak mavi; gri yerine laciverte çalan yüzeylerde. + Inner Gamer + Gökkuşağında dolaşan renkler. Android\'in animasyon ayarlarına uyar. + Hız + Renklerin ne kadar hızlı değiştiği. Geçiş Kendi birincil ve ikincil renklerin, düğmelerde ve vurgularda harmanlanır. Birincil diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index e1753cba3..b361c61fa 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -1429,6 +1429,10 @@ Чорне тло, білий текст і синій для всього вибраного, у фокусі чи натиснутого. Електричний темно-синій Яскравіший синій на поверхнях із темно-синім відтінком замість сірого. + Inner Gamer + Кольори, що плавно змінюються по всій веселці. Враховує налаштування анімації Android. + Швидкість + Як швидко змінюються кольори. Градієнт Ваші основний і додатковий кольори, що плавно переходять на кнопках і виділеннях. Основний diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 91bf30f13..a5aa6f9ff 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -1387,6 +1387,10 @@ Nền đen, chữ trắng, xanh cho mọi thứ được chọn, lấy nét hoặc nhấn. Xanh điện navy Màu xanh sáng hơn, trên các bề mặt nghiêng về navy thay vì xám. + Inner Gamer + Màu sắc chuyển dần qua cả dải cầu vồng. Tuân theo cài đặt hiệu ứng động của Android. + Tốc độ + Màu đổi nhanh đến mức nào. Chuyển màu Màu chính và màu phụ do bạn chọn, hòa trộn trên các nút và phần nổi bật. Chính diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index e4a6c6933..ca9a75287 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -1465,6 +1465,10 @@ 黑色背景、白色文字,选中、聚焦或按下的元素使用蓝色。 电光藏青 更明亮的蓝色,配以偏藏青而非灰色的界面。 + Inner Gamer + 在彩虹色之间循环变化的颜色。遵循 Android 的动画设置。 + 速度 + 颜色循环的快慢。 渐变 你自己选择的主色和辅色,在按钮和高亮处渐变呈现。 主色 diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index 72cc6cdda..74460d151 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -1389,6 +1389,10 @@ 黑色背景、白色文字,所有已選取、焦點所在或按下的項目都使用藍色。 電光海軍藍 更明亮的藍色,搭配偏海軍藍而非灰色的介面。 + Inner Gamer + 在彩虹色之間循環變化的顏色。遵循 Android 的動畫設定。 + 速度 + 顏色循環的快慢。 漸層 你自行選擇的主色與輔色,在按鈕與強調處以漸層呈現。 主色 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 19d11a7e8..795be8c5e 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -1389,6 +1389,10 @@ 黑色背景、白色文字,選取、聚焦或按下的項目使用藍色。 電光藏青 更亮的藍色,搭配偏藏青而非灰色的介面。 + Inner Gamer + 在彩虹色之間循環變化的顏色。遵循 Android 的動畫設定。 + 速度 + 顏色循環的快慢。 漸層 你自行選擇的主色與輔色,在按鈕與強調處以漸層呈現。 主色 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cf80262af..e62952c4f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1458,6 +1458,10 @@ Black ground, white type, the blue for everything selected, focused or pressed. Electric navy The blue brighter, on surfaces that lean navy instead of grey. + Inner Gamer + Colors that cycle through the rainbow. Follows Android\'s animation settings. + Speed + How fast the colors cycle. Gradient Your own primary and secondary colors, blended on buttons and highlights. Primary diff --git a/app/src/test/java/com/droiddeck/launcher/ui/ThemeColorsTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/ThemeColorsTest.kt index b6f4de204..851c3dfc4 100644 --- a/app/src/test/java/com/droiddeck/launcher/ui/ThemeColorsTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/ui/ThemeColorsTest.kt @@ -132,4 +132,40 @@ class ThemeColorsTest { assertTrue(Themes.all.any { it.id == Themes.GRADIENT }) assertEquals(Themes.GRAPHITE, Themes.byId("nonsense").id) } + + @Test fun innerGamerSitsJustAboveGradientInTheThemeList() { + val ids = Themes.all.map { it.id } + assertEquals(ids.indexOf(Themes.GRADIENT) - 1, ids.indexOf(Themes.INNER_GAMER)) + } + + @Test fun innerGamerButtonTextIsReadableAlongTheGradientAtEveryHue() { + var hue = 0f + while (hue < 360f) { + val p = Themes.innerGamer(hue) + assertEquals(Themes.INNER_GAMER, p.id) + for (i in 0..100) { + val f = i / 100f + val blend = Color( + p.primary.red + (p.primary2.red - p.primary.red) * f, + p.primary.green + (p.primary2.green - p.primary.green) * f, + p.primary.blue + (p.primary2.blue - p.primary.blue) * f, + ) + assertTrue("hue $hue at $f", contrast(p.onPrimary, blend) >= 4.49f) + } + hue += Themes.RGB_HUE_STEP + } + } + + @Test fun innerGamerFollowsTheHueAndTheSpeedIsSavedAndKeptInRange() { + Themes.setRgbHue(120f) + assertEquals(Themes.innerGamer(120f).primary, Themes.byId(Themes.INNER_GAMER).primary) + Themes.setRgbSpeed(app, 7) + assertEquals(7, SessionPrefs.rgbSpeed(app)) + Themes.setRgbSpeed(app, 99) + assertEquals(Themes.RGB_SPEEDS, Themes.rgbSpeed) + Themes.setRgbSpeed(app, 0) + assertEquals(1, SessionPrefs.rgbSpeed(app)) + Themes.setRgbSpeed(app, 1) + assertTrue(Themes.rgbCycleMillis(1) > Themes.rgbCycleMillis(10)) + } } diff --git a/docs/development/themes.md b/docs/development/themes.md index b7f1a01eb..e7855c43f 100644 --- a/docs/development/themes.md +++ b/docs/development/themes.md @@ -23,3 +23,17 @@ Setup shows Primary and Secondary under the theme when Gradient is chosen, each square. It opens `ColorPickerDialog`: a wheel (hue and saturation, touch), hue / saturation / brightness sliders (they work with a controller) and a hex box, with the old and new colour side by side. OK applies it. + +## Inner Gamer theme + +`Themes.INNER_GAMER` is an RGB theme: the accent walks round the colour wheel. It sits just above +Gradient in the list, and Setup shows a Speed slider (1 to 10, `SessionPrefs.rgbSpeed`) under the +theme while it is chosen. + +`Themes.innerGamer(hue)` builds the palette for one hue through `ThemeColors.palette`, so button text +keeps the same contrast along the gradient at every hue. `DroidDeckTheme` runs one +`rememberInfiniteTransition` while Inner Gamer is chosen, so the system's animation clock drives it. +The duration goes through `Motion.ms`, so the colour holds still when animations are off in the app +or in Android's own settings. The hue is written to `Themes.rgbHue` in 3° steps, which restyles the +app only when the colour visibly moves; a slow cycle costs few recompositions. + From c7961bdacfe91b120feadaa160d789407da9797b Mon Sep 17 00:00:00 2001 From: FX192 <120828496+FX192@users.noreply.github.com> Date: Fri, 23 Oct 2026 05:12:36 +0300 Subject: [PATCH 11/59] Fix the build: rename moveRgbHue to avoid clashing with the rgbHue setter; Hindi verb agreement --- app/src/main/java/com/droiddeck/launcher/ui/Theme.kt | 4 ++-- app/src/main/res/values-hi/strings.xml | 2 +- .../test/java/com/droiddeck/launcher/ui/ThemeColorsTest.kt | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/Theme.kt b/app/src/main/java/com/droiddeck/launcher/ui/Theme.kt index 4eae384e2..01196ae87 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/Theme.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/Theme.kt @@ -63,7 +63,7 @@ object Themes { var rgbHue by mutableStateOf(0f) private set - fun setRgbHue(hue: Float) { + fun moveRgbHue(hue: Float) { rgbHue = hue } @@ -174,5 +174,5 @@ private fun InnerGamerHue(speed: Int) { 0f, 360f, infiniteRepeatable(tween(Motion.ms(Themes.rgbCycleMillis(speed)).coerceAtLeast(1), easing = LinearEasing), RepeatMode.Restart), label = "hue", ) val step = Themes.RGB_HUE_STEP - SideEffect { Themes.setRgbHue((hue / step).toInt() * step) } + SideEffect { Themes.moveRgbHue((hue / step).toInt() * step) } } diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 5113e7764..7dbcf2049 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -1402,7 +1402,7 @@ इलेक्ट्रिक नेवी ब्लू रंग ज़्यादा चमकीला, उन सतहों पर जो ग्रे के बजाय नेवी की ओर झुकती हैं। Inner Gamer - इंद्रधनुष के रंगों में बदलते रंग। Android की एनिमेशन सेटिंग का पालन करता है। + इंद्रधनुष के रंगों में बदलते रंग। Android की एनिमेशन सेटिंग का पालन करती है। गति रंग कितनी तेज़ी से बदलें। ग्रेडिएंट diff --git a/app/src/test/java/com/droiddeck/launcher/ui/ThemeColorsTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/ThemeColorsTest.kt index 851c3dfc4..fdbb8b44b 100644 --- a/app/src/test/java/com/droiddeck/launcher/ui/ThemeColorsTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/ui/ThemeColorsTest.kt @@ -157,7 +157,7 @@ class ThemeColorsTest { } @Test fun innerGamerFollowsTheHueAndTheSpeedIsSavedAndKeptInRange() { - Themes.setRgbHue(120f) + Themes.moveRgbHue(120f) assertEquals(Themes.innerGamer(120f).primary, Themes.byId(Themes.INNER_GAMER).primary) Themes.setRgbSpeed(app, 7) assertEquals(7, SessionPrefs.rgbSpeed(app)) From 6b010baa9e06e036be549dd34966ba6686605219 Mon Sep 17 00:00:00 2001 From: FX192 <120828496+FX192@users.noreply.github.com> Date: Fri, 23 Oct 2026 08:07:33 +0300 Subject: [PATCH 12/59] Inner Gamer: read the animated hue while composing so the colors keep cycling --- app/src/main/java/com/droiddeck/launcher/ui/Theme.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/Theme.kt b/app/src/main/java/com/droiddeck/launcher/ui/Theme.kt index 01196ae87..f633e0c12 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/Theme.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/Theme.kt @@ -173,6 +173,6 @@ private fun InnerGamerHue(speed: Int) { val hue by rememberInfiniteTransition(label = "innerGamer").animateFloat( 0f, 360f, infiniteRepeatable(tween(Motion.ms(Themes.rgbCycleMillis(speed)).coerceAtLeast(1), easing = LinearEasing), RepeatMode.Restart), label = "hue", ) - val step = Themes.RGB_HUE_STEP - SideEffect { Themes.moveRgbHue((hue / step).toInt() * step) } + val stepped = (hue / Themes.RGB_HUE_STEP).toInt() * Themes.RGB_HUE_STEP + SideEffect { Themes.moveRgbHue(stepped) } } From 5a1819a7134c3fa0f261089175a17088a37ab6ac Mon Sep 17 00:00:00 2001 From: FX192 <120828496+FX192@users.noreply.github.com> Date: Fri, 23 Oct 2026 08:28:23 +0300 Subject: [PATCH 13/59] Themes.current also reloads the saved Inner Gamer speed --- app/src/main/java/com/droiddeck/launcher/ui/Theme.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/Theme.kt b/app/src/main/java/com/droiddeck/launcher/ui/Theme.kt index f633e0c12..13f01211d 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/Theme.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/Theme.kt @@ -95,7 +95,7 @@ object Themes { /** The theme the person chose, with the custom colours read first; for code that is not in a composition. */ fun current(context: Context): Palette { val id = SessionPrefs.theme(context) - if (id == GRADIENT) loadGradient(context) + if (id == GRADIENT || id == INNER_GAMER) loadGradient(context) return byId(id) } From 5f341dda16af51c6c77f84c9d83eeff603be5309 Mon Sep 17 00:00:00 2001 From: FX192 <120828496+FX192@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:12:18 +0300 Subject: [PATCH 14/59] Inner Gamer: a new speed takes over at once, continuing from the current colour --- app/src/main/java/com/droiddeck/launcher/ui/Theme.kt | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/Theme.kt b/app/src/main/java/com/droiddeck/launcher/ui/Theme.kt index 13f01211d..24f01b611 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/Theme.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/Theme.kt @@ -10,6 +10,7 @@ import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween import androidx.compose.runtime.Composable +import androidx.compose.runtime.key import androidx.compose.runtime.SideEffect import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.staticCompositionLocalOf @@ -170,9 +171,16 @@ fun DroidDeckTheme(theme: String? = null, appScale: Int = AppUiPrefs.DEFAULT_SCA @Composable private fun InnerGamerHue(speed: Int) { if (Motion.scale == 0f) return + key(speed) { InnerGamerCycle(speed) } +} + +/** One run of the cycle at one speed, picking up from the colour on screen so a new speed takes over at once without a jump. */ +@Composable +private fun InnerGamerCycle(speed: Int) { + val start = remember { Themes.rgbHue } val hue by rememberInfiniteTransition(label = "innerGamer").animateFloat( - 0f, 360f, infiniteRepeatable(tween(Motion.ms(Themes.rgbCycleMillis(speed)).coerceAtLeast(1), easing = LinearEasing), RepeatMode.Restart), label = "hue", + start, start + 360f, infiniteRepeatable(tween(Motion.ms(Themes.rgbCycleMillis(speed)).coerceAtLeast(1), easing = LinearEasing), RepeatMode.Restart), label = "hue", ) - val stepped = (hue / Themes.RGB_HUE_STEP).toInt() * Themes.RGB_HUE_STEP + val stepped = ((hue % 360f) / Themes.RGB_HUE_STEP).toInt() * Themes.RGB_HUE_STEP SideEffect { Themes.moveRgbHue(stepped) } } From b88eebe09bbe4e988d70fdfa8c47c72206448a07 Mon Sep 17 00:00:00 2001 From: FX192 <120828496+FX192@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:29:04 +0300 Subject: [PATCH 15/59] Inner Gamer: let Compose apply the animator scale once --- app/src/main/java/com/droiddeck/launcher/ui/Theme.kt | 2 +- docs/development/themes.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/Theme.kt b/app/src/main/java/com/droiddeck/launcher/ui/Theme.kt index 24f01b611..14527f594 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/Theme.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/Theme.kt @@ -179,7 +179,7 @@ private fun InnerGamerHue(speed: Int) { private fun InnerGamerCycle(speed: Int) { val start = remember { Themes.rgbHue } val hue by rememberInfiniteTransition(label = "innerGamer").animateFloat( - start, start + 360f, infiniteRepeatable(tween(Motion.ms(Themes.rgbCycleMillis(speed)).coerceAtLeast(1), easing = LinearEasing), RepeatMode.Restart), label = "hue", + start, start + 360f, infiniteRepeatable(tween(Themes.rgbCycleMillis(speed).coerceAtLeast(1), easing = LinearEasing), RepeatMode.Restart), label = "hue", ) val stepped = ((hue % 360f) / Themes.RGB_HUE_STEP).toInt() * Themes.RGB_HUE_STEP SideEffect { Themes.moveRgbHue(stepped) } diff --git a/docs/development/themes.md b/docs/development/themes.md index e7855c43f..4bb122c6f 100644 --- a/docs/development/themes.md +++ b/docs/development/themes.md @@ -33,7 +33,7 @@ theme while it is chosen. `Themes.innerGamer(hue)` builds the palette for one hue through `ThemeColors.palette`, so button text keeps the same contrast along the gradient at every hue. `DroidDeckTheme` runs one `rememberInfiniteTransition` while Inner Gamer is chosen, so the system's animation clock drives it. -The duration goes through `Motion.ms`, so the colour holds still when animations are off in the app -or in Android's own settings. The hue is written to `Themes.rgbHue` in 3° steps, which restyles the +Compose applies Android's animator scale to it, and the colour holds still when animations are off +in the app (`Motion.scale`) or in Android's own settings. The hue is written to `Themes.rgbHue` in 3° steps, which restyles the app only when the colour visibly moves; a slow cycle costs few recompositions. From 53c63b6dc2dc0662a4f8da6ec7a817c19184a771 Mon Sep 17 00:00:00 2001 From: 0x1Nawaf Date: Fri, 23 Oct 2026 15:43:52 +0000 Subject: [PATCH 16/59] fix: keep a slightly early vsync tick for frame pacing --- app/src/main/cpp/waylandcomp/src/compositor.c | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/app/src/main/cpp/waylandcomp/src/compositor.c b/app/src/main/cpp/waylandcomp/src/compositor.c index 95a8bf83a..fb15d50c3 100644 --- a/app/src/main/cpp/waylandcomp/src/compositor.c +++ b/app/src/main/cpp/waylandcomp/src/compositor.c @@ -807,11 +807,21 @@ static uint32_t nominal_refresh_ns(void) { return (uint32_t)(1000000000000LL / (int64_t)mhz); } +/* Gamescope paces from this timestamp (#717). The Choreographer tick is often still + * slightly ahead of this thread, because the UI thread queues the callback, and that + * used to throw the tick away (render_ns < tick) and report the draw time instead. + * Keep a tick within one refresh of the draw, ahead or behind. A missing tick, or one + * more than a refresh away, falls back to the draw time. Never report the next refresh: + * gamescope would wake a frame late. */ static int64_t presented_ns(int64_t render_ns) { - if (!g_tick_vsync_ns || render_ns - g_last_vsync_ns > (int64_t)nominal_refresh_ns() || - render_ns < g_tick_vsync_ns) + int64_t tick = g_tick_vsync_ns; + int64_t refresh = (int64_t)nominal_refresh_ns(); + if (!tick || render_ns - g_last_vsync_ns > refresh) return render_ns; - return g_tick_vsync_ns; + int64_t delta = render_ns - tick; + if (delta > refresh || -delta > refresh) + return render_ns; + return tick; } static void feedback_present_all(struct wl_list *list, int64_t t) { From 7aeb7779bbdbaec65b1a5520653abbcd7091610e Mon Sep 17 00:00:00 2001 From: ovurrsl Date: Thu, 22 Oct 2026 21:27:28 +0300 Subject: [PATCH 17/59] fix: fall back to Valve's stable Steam client when the beta one keeps failing The native arm64 client follows steamdeck_publicbeta, so a Valve beta regression reaches every user at once. If the repaired beta client is still started twice without being shown, or the beta manifest carries no arm64 client, droiddeck-steam-install now installs steamdeck_stable, keeps it, and logs why (listing the manifest's blocks in the second case). The session asks the installer for the channel, so -clientbeta and package/beta stay in sync. BL_STEAM_CHANNEL or ~/.config/droiddeck/steam-channel (beta|stable) pins it. Valve's 2026-10-15 beta moved the x86 client into a Steam Runtime container. In case the arm64 client follows, its session now exports STEAM_STEAMRT_RECURSION_GUARD=1 and, only if steamrtarm64 carries pv-runtime or a steam-runtime-check-requirements probe, starts it through the same steamrt shim the x86-64 client uses (now droiddeck-steamrt-shim) and logs it. --- .../launcher/session/SessionFiles.kt | 1 + docs/development/arm64-steam-client.md | 42 +++++++ docs/development/x86-64-steam-client.md | 3 +- .../overlay/usr/local/bin/droiddeck-session | 13 +- .../usr/local/bin/droiddeck-steam-install | 96 ++++++++++---- .../overlay/usr/local/bin/droiddeck-steam-x64 | 35 +----- .../usr/local/bin/droiddeck-steamrt-shim | 49 ++++++++ tools/tests/test_steam_install.py | 88 ++++++++++++- tools/tests/test_steamrt_shim.py | 117 ++++++++++++++++++ 9 files changed, 379 insertions(+), 65 deletions(-) create mode 100644 docs/development/arm64-steam-client.md create mode 100755 tools/linuxfs/overlay/usr/local/bin/droiddeck-steamrt-shim create mode 100644 tools/tests/test_steamrt_shim.py 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 0dab068d8..47ffb2c93 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt @@ -78,6 +78,7 @@ object SessionFiles { "usr/local/bin/droiddeck-steam-games" to "usr/local/bin/droiddeck-steam-games", "usr/local/bin/droiddeck-pad-defaults" to "usr/local/bin/droiddeck-pad-defaults", "usr/local/bin/droiddeck-steam-x64" to "usr/local/bin/droiddeck-steam-x64", + "usr/local/bin/droiddeck-steamrt-shim" to "usr/local/bin/droiddeck-steamrt-shim", "usr/local/bin/droiddeck-x64-arm-proton" to "usr/local/bin/droiddeck-x64-arm-proton", "usr/local/bin/droiddeck-x64-arm-webhelper" to "usr/local/bin/droiddeck-x64-arm-webhelper", "usr/local/bin/droiddeck-x64-webhelper" to "usr/local/bin/droiddeck-x64-webhelper", diff --git a/docs/development/arm64-steam-client.md b/docs/development/arm64-steam-client.md new file mode 100644 index 000000000..bb1efc04f --- /dev/null +++ b/docs/development/arm64-steam-client.md @@ -0,0 +1,42 @@ +# Native arm64 Steam client: channel and runtime + +The default client is Valve's arm64 Linux build (`steamrtarm64/steam`). Valve does not support it, but +publishes it on the Steam Deck client channels. `droiddeck-steam-install` installs it and +`droiddeck-session` starts it with `STEAM_RUNTIME=1`. + +## Channel + +- The default is `steamdeck_publicbeta` (manifest `steam_client_steamdeck_publicbeta_linuxarm64`). + Valve also publishes `steam_client_steamdeck_stable_linuxarm64`. +- `droiddeck-steam-install --channel` prints the channel in use. The session passes that value as + `-clientbeta` and writes it to `package/beta`. The two must match, or the client loops on rc 42. +- Setting: `BL_STEAM_CHANNEL`, otherwise `~/.config/droiddeck/steam-channel`, set to `beta` or + `stable`. A set channel never changes on its own. +- With no setting, the client falls back to `steamdeck_stable` automatically. It stays on stable + (`package/droiddeck-channel`) until a setting says otherwise. The fallback happens: + - when the client was started two more times without being shown after the start-failure repair + (`package/droiddeck-unconfirmed`: two unconfirmed starts trigger a repair, which writes + `repaired`; two more unconfirmed starts trigger the fallback); + - when the beta manifest has no `*_linuxarm64_linuxarm64` component. The install log then lists the + manifest's block names, so a renamed or removed arm64 build can be seen. +- The x86-64 client keeps `steamdeck_publicbeta` (see [x86-64-steam-client.md](x86-64-steam-client.md)). + +## Steam Runtime container + +Valve's 2026-10-15 beta made the x86 Linux client 64-bit and put it in a Steam Runtime container +(pressure-vessel). The container needs unprivileged user and mount namespaces and a D-Bus session bus, +which an Android app does not have. The x86-64 path already works around this. The arm64 client gets the +same treatment if Valve containerizes it too: + +- `STEAM_STEAMRT_RECURSION_GUARD=1` is exported for both clients. It tells the client that it is + already inside its container, so after an update it does not re-execute itself through + `pressure-vessel-wrap` (bwrap). +- `droiddeck-steamrt-shim ` builds the shim when the client carries + `pv-runtime/steam-runtime-steamrt` or a `steam-runtime-check-requirements` probe: + - Valve's runtime is linked file by file; + - the probe is replaced with `exit 0`; + - its own `steam-runtime-launch-client` runs commands directly. + + For the arm64 client the session then starts it with `STEAM_RUNTIME_STEAMRT` and `PATH` pointing + at `~/.droiddeck-steamrt-shim` and logs "Valve containerized the arm64 Steam client". Without those + files the shim is not built and the start is unchanged. diff --git a/docs/development/x86-64-steam-client.md b/docs/development/x86-64-steam-client.md index f11923fe7..3e0d867a2 100644 --- a/docs/development/x86-64-steam-client.md +++ b/docs/development/x86-64-steam-client.md @@ -53,7 +53,8 @@ arm64 client down first. ("Steam now requires user namespaces") when `steam-runtime-check-requirements`' bwrap probe cannot create a user namespace, which no Android app can. `STEAM_RUNTIME_STEAMRT` points at `steamrt-shim`: Valve's runtime linked file by file with only the probe replaced (`exit 0`). proot - is the container, as for the arm64 client. + is the container, as for the arm64 client. `droiddeck-steamrt-shim` builds it, and builds the same + shim for the arm64 client if that one is containerized too ([arm64-steam-client.md](arm64-steam-client.md)). - **Re-exec after an update.** The client re-executes itself inside the SteamRT container through `pressure-vessel-wrap` (bwrap). `STEAM_STEAMRT_RECURSION_GUARD=1` says it is already inside one; the FEX rootfs with the client's `steamrt64` libraries is that container. diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session index 688d0c19b..c33b78552 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session @@ -403,6 +403,11 @@ case $mode in droiddeck-steam-x64 --sync-login || true fi steam_client=("$steam_root/steamrtarm64/steam") + _bl_srt_shim=$HOME/.droiddeck-steamrt-shim + if droiddeck-steamrt-shim "$steam_root/steamrtarm64" "$_bl_srt_shim"; then + echo "== steam: Valve containerized the arm64 Steam client (it carries a Steam Runtime container); starting it through $_bl_srt_shim" + steam_client=(env STEAM_RUNTIME_STEAMRT="$_bl_srt_shim" PATH="$_bl_srt_shim/bin:$PATH" "${steam_client[@]}") + fi fi _bl_proton_root=$steam_root if [ "$steam_x64" = 1 ]; then @@ -746,10 +751,13 @@ BUSCONF fi # The UI bridge launches SteamVR, which needs the guest agent's library environment. - _bl_compat_env=(env -u LD_LIBRARY_PATH -u STEAM_RUNTIME) + _bl_compat_env=(env -u LD_LIBRARY_PATH -u STEAM_RUNTIME -u STEAM_STEAMRT_RECURSION_GUARD) [ "${LD_LIBRARY_PATH+x}" ] && _bl_compat_env+=("LD_LIBRARY_PATH=$LD_LIBRARY_PATH") [ "${STEAM_RUNTIME+x}" ] && _bl_compat_env+=("STEAM_RUNTIME=$STEAM_RUNTIME") + [ "${STEAM_STEAMRT_RECURSION_GUARD+x}" ] && _bl_compat_env+=("STEAM_STEAMRT_RECURSION_GUARD=$STEAM_STEAMRT_RECURSION_GUARD") export STEAM_RUNTIME=1 + # A containerized client re-executes itself through pressure-vessel-wrap (bwrap) unless told it is already inside. + export STEAM_STEAMRT_RECURSION_GUARD=1 export LD_LIBRARY_PATH=$steam_root/steamrtarm64:$steam_root/steamrtarm64/panorama${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH} export SDL_VIDEO_X11_DGAMOUSE=0 decky_loader=$HOME/homebrew/services/PluginLoader @@ -829,6 +837,9 @@ BUSCONF unset _stale _f fi steam_channel=steamdeck_publicbeta + if [ "$steam_x64" = 0 ]; then + steam_channel=$(droiddeck-steam-install --channel 2>/dev/null || echo steamdeck_publicbeta) + fi _bl_lang=() [ -n "${BL_STEAM_LANGUAGE:-}" ] && _bl_lang=(-language "$BL_STEAM_LANGUAGE") steam_beta=$steam_root/package/beta diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-install b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-install index 3ce263c0d..8ebef7ea5 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-install +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-install @@ -1,9 +1,26 @@ #!/bin/bash set -eu cdn_list="https://client-update.steamstatic.com https://client-update.akamai.steamstatic.com https://client-update.fastly.steamstatic.com" -steam_channel=steamdeck_publicbeta -manifest=steam_client_${steam_channel}_linuxarm64 +beta_channel=steamdeck_publicbeta +stable_channel=steamdeck_stable steam_root=${XDG_DATA_HOME:-$HOME/.local/share}/Steam +channel_state=$steam_root/package/droiddeck-channel +pinned=1 +wanted=${BL_STEAM_CHANNEL:-$(cat "$HOME/.config/droiddeck/steam-channel" 2>/dev/null || true)} +case $wanted in + stable|"$stable_channel") steam_channel=$stable_channel ;; + beta|"$beta_channel") steam_channel=$beta_channel ;; + *) + pinned= + steam_channel=$beta_channel + [ "$(cat "$channel_state" 2>/dev/null)" = "$stable_channel" ] && steam_channel=$stable_channel + ;; +esac +if [ "${1:-}" = --channel ]; then + echo "$steam_channel" + exit 0 +fi +manifest=steam_client_${steam_channel}_linuxarm64 stamp=$steam_root/package/droiddeck-installed legacy_stamp=$steam_root/package/bannerlator-installed unconfirmed=$steam_root/package/droiddeck-unconfirmed @@ -12,11 +29,21 @@ if [ -f "$legacy_stamp" ] && [ ! -f "$stamp" ]; then mv -f "$legacy_stamp" "$stamp" || true fi repair="" +fallback="" +starts=0 +[ -f "$unconfirmed" ] && starts=$(grep -cx start "$unconfirmed" || true) if [ -e "$repair_request" ]; then repair="requested" -elif [ -f "$unconfirmed" ] && ! grep -qx repaired "$unconfirmed" \ - && [ "$(grep -cx start "$unconfirmed")" -ge 2 ]; then - repair="the client was started $(grep -cx start "$unconfirmed") times without showing anything" +elif [ "$starts" -ge 2 ] && ! grep -qx repaired "$unconfirmed"; then + repair="the client was started $starts times without showing anything" +elif [ "$starts" -ge 2 ] && [ -z "$pinned" ] && [ "$steam_channel" = "$beta_channel" ] && [ "${1:-}" != --sync ]; then + repair="the repaired $beta_channel client was started $starts more times without showing anything" + fallback=1 +fi +if [ -n "$fallback" ]; then + echo "== STEP $(date -u +%H:%M:%S) falling back to Valve's $stable_channel Steam client: $repair" + steam_channel=$stable_channel + manifest=steam_client_${steam_channel}_linuxarm64 fi if [ -n "$repair" ]; then echo "== STEP $(date -u +%H:%M:%S) repairing the Steam client ($repair)" @@ -42,20 +69,42 @@ fi work=$(mktemp -d) trap 'rm -rf "$work"' EXIT -cdn="" -for candidate in $cdn_list; do - if curl -fsSL --connect-timeout 10 --max-time 30 --retry 2 --retry-connrefused --retry-delay 3 \ - "$candidate/$manifest" -o "$work/manifest"; then - cdn=$candidate - break +fetch_manifest() { + cdn="" + for candidate in $cdn_list; do + if curl -fsSL --connect-timeout 10 --max-time 30 --retry 2 --retry-connrefused --retry-delay 3 \ + "$candidate/$manifest" -o "$work/manifest"; then + cdn=$candidate + break + fi + done + [ -n "$cdn" ] || { echo "droiddeck-steam-install: no Steam client CDN answered for $manifest" >&2; exit 1; } + cdn_order=("$cdn") + for candidate in $cdn_list; do + [ "$candidate" = "$cdn" ] || cdn_order+=("$candidate") + done + version=$(awk -F'"' '$2 == "version" { print $4; exit }' "$work/manifest") + awk -F'"' ' + /^\t"/ && NF >= 3 && $4 == "" { block = $2 } + $2 == "file" { file[block] = $4 } + $2 == "sha2" { sha[block] = $4 } + END { for (b in file) if (b ~ /_all$/ || b ~ /_linuxarm64_linuxarm64$/) print b, file[b], sha[b] } + ' "$work/manifest" | sort > "$work/parts" + [ -s "$work/parts" ] && return 0 + echo "droiddeck-steam-install: no client components in $manifest; its blocks are:" \ + "$(awk -F'"' '/^\t"/ && NF >= 3 && $4 == "" { printf "%s ", $2 }' "$work/manifest")" >&2 + return 1 +} +if ! fetch_manifest; then + if [ -n "$sync" ] || [ -n "$pinned" ] || [ "$steam_channel" = "$stable_channel" ]; then + exit 1 fi -done -[ -n "$cdn" ] || { echo "droiddeck-steam-install: no Steam client CDN answered for $manifest" >&2; exit 1; } -cdn_order=("$cdn") -for candidate in $cdn_list; do - [ "$candidate" = "$cdn" ] || cdn_order+=("$candidate") -done -version=$(awk -F'"' '$2 == "version" { print $4; exit }' "$work/manifest") + echo "== STEP $(date -u +%H:%M:%S) falling back to Valve's $stable_channel Steam client: $manifest has no arm64 client" + steam_channel=$stable_channel + manifest=steam_client_${steam_channel}_linuxarm64 + fallback=1 + fetch_manifest || exit 1 +fi if [ -n "$sync" ]; then installed=$(awk -F'"' '$2 == "version" { print $4; exit }' "$steam_root/package/$manifest.manifest" 2>/dev/null || true) if [ -x "$steam_root/steamrtarm64/steam" ] && [ "$installed" = "$version" ]; then @@ -64,13 +113,6 @@ if [ -n "$sync" ]; then fi echo "== STEP $(date -u +%H:%M:%S) updating the arm64 Steam client to $version (was ${installed:-none}) for the x86-64 client" fi -awk -F'"' ' - /^\t"/ && NF >= 3 && $4 == "" { block = $2 } - $2 == "file" { file[block] = $4 } - $2 == "sha2" { sha[block] = $4 } - END { for (b in file) if (b ~ /_all$/ || b ~ /_linuxarm64_linuxarm64$/) print b, file[b], sha[b] } -' "$work/manifest" | sort > "$work/parts" -[ -s "$work/parts" ] || { echo "droiddeck-steam-install: no client components in $manifest" >&2; exit 1; } total=$(grep -c . "$work/parts" 2>/dev/null || echo 0) mkdir -p "$steam_root/package" @@ -117,6 +159,10 @@ ln -sfn "$steam_root/linuxarm64" "$HOME/.steam/sdkarm64" ln -sfn "$steam_root/steamrtarm64" "$HOME/.steam/binarm64" ln -sfn "$steam_root/linux32" "$HOME/.steam/sdk32" echo "$version" > "$stamp" +if [ -n "$fallback" ]; then + echo "$stable_channel" > "$channel_state" + echo "== STEP $(date -u +%H:%M:%S) the Steam client now follows $stable_channel (beta in ~/.config/droiddeck/steam-channel returns to $beta_channel)" +fi if [ -n "$repair" ]; then echo repaired > "$unconfirmed" rm -f "$repair_request" diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-x64 b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-x64 index e52a4e985..df9ec941b 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-x64 +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-x64 @@ -520,39 +520,8 @@ ln -sfn "$STEAM_X64/ubuntu12_32" "$X64_HOME/.steam/bin32" ln -sfn "$STEAM_X64/ubuntu12_64" "$X64_HOME/.steam/bin64" ln -sfn "$STEAM_X64/linux32" "$X64_HOME/.steam/sdk32" ln -sfn "$STEAM_X64/linux64" "$X64_HOME/.steam/sdk64" -# The client's bwrap probe (steam-runtime-check-requirements) needs user namespaces, which no Android app has. -srt_real=$STEAM_X64/steamrt64/pv-runtime/steam-runtime-steamrt -srt_shim=$X64_HOME/steamrt-shim -if [ -d "$srt_real" ]; then - rm -rf "$srt_shim" - mkdir -p "$srt_shim/bin" - for e in "$srt_real"/* "$srt_real"/.[!.]*; do - [ -e "$e" ] || continue - [ "$(basename "$e")" = bin ] || ln -s "$e" "$srt_shim/$(basename "$e")" - done - for e in "$srt_real"/bin/*; do ln -s "$e" "$srt_shim/bin/$(basename "$e")"; done - rm -f "$srt_shim/bin/steam-runtime-check-requirements" - printf '#!/bin/sh -exit 0 -' > "$srt_shim/bin/steam-runtime-check-requirements" - chmod 755 "$srt_shim/bin/steam-runtime-check-requirements" - rm -f "$srt_shim/bin/steam-runtime-launch-client" - cat > "$srt_shim/bin/steam-runtime-launch-client" <<'LAUNCH' -#!/bin/sh -while [ $# -gt 0 ]; do - case $1 in - --) shift; break ;; - --directory=*) cd "${1#--directory=}" || exit 127 ;; - --env=*) export "${1#--env=}" ;; - --unset-env=*) unset "${1#--unset-env=}" ;; - esac - shift -done -[ $# -gt 0 ] || exit 0 -exec "$@" -LAUNCH - chmod 755 "$srt_shim/bin/steam-runtime-launch-client" -fi +# Valve's Steam Runtime container needs user namespaces and a session-bus launcher; steamrt-shim stands in for both. +droiddeck-steamrt-shim "$STEAM_X64/steamrt64" "$X64_HOME/steamrt-shim" || true # Valve's platform libraries ship without soname links (pressure-vessel makes them); ldconfig adds them here. srt_libs=$X64_HOME/steamrt-libs rm -rf "$srt_libs" diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steamrt-shim b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steamrt-shim new file mode 100755 index 000000000..96e851bc9 --- /dev/null +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steamrt-shim @@ -0,0 +1,49 @@ +#!/bin/bash +# droiddeck-steamrt-shim +# Builds from the Steam Runtime container the client carries and exits 0, or removes it and exits 1 +# when the client carries none. +set -eu +client=$1 +shim=$2 +srt_real=$client/pv-runtime/steam-runtime-steamrt +if [ ! -d "$srt_real" ]; then + probe=$(find "$client" -maxdepth 4 -name steam-runtime-check-requirements -path '*/bin/*' -print -quit 2>/dev/null || true) + srt_real=${probe:+${probe%/bin/*}} +fi +if [ -z "$srt_real" ] || [ ! -d "$srt_real" ]; then + rm -rf "$shim" + exit 1 +fi +rm -rf "$shim" +mkdir -p "$shim/bin" +for e in "$srt_real"/* "$srt_real"/.[!.]*; do + [ -e "$e" ] || continue + [ "$(basename "$e")" = bin ] || ln -s "$e" "$shim/$(basename "$e")" +done +for e in "$srt_real"/bin/*; do + [ -e "$e" ] || continue + ln -s "$e" "$shim/bin/$(basename "$e")" +done +# The client's bwrap probe (steam-runtime-check-requirements) needs user namespaces, which no Android app has. +rm -f "$shim/bin/steam-runtime-check-requirements" +printf '#!/bin/sh +exit 0 +' > "$shim/bin/steam-runtime-check-requirements" +chmod 755 "$shim/bin/steam-runtime-check-requirements" +# Valve's launcher asks a service on the session bus; proot is already the container, so the command runs here. +rm -f "$shim/bin/steam-runtime-launch-client" +cat > "$shim/bin/steam-runtime-launch-client" <<'LAUNCH' +#!/bin/sh +while [ $# -gt 0 ]; do + case $1 in + --) shift; break ;; + --directory=*) cd "${1#--directory=}" || exit 127 ;; + --env=*) export "${1#--env=}" ;; + --unset-env=*) unset "${1#--unset-env=}" ;; + esac + shift +done +[ $# -gt 0 ] || exit 0 +exec "$@" +LAUNCH +chmod 755 "$shim/bin/steam-runtime-launch-client" diff --git a/tools/tests/test_steam_install.py b/tools/tests/test_steam_install.py index 8692d5649..256e71133 100644 --- a/tools/tests/test_steam_install.py +++ b/tools/tests/test_steam_install.py @@ -56,7 +56,7 @@ cp "$TEST_CDN/${url##*/}" "$out" self.env = dict(os.environ, HOME=str(self.home), XDG_DATA_HOME=str(self.data), PATH=str(self.bin) + os.pathsep + os.environ['PATH'], TEST_CDN=str(self.cdn), TEST_REQUESTS=str(self.requests)) - for channel in ('steamdeck_publicbeta',): + for channel in ('steamdeck_publicbeta', 'steamdeck_stable'): archive = self.cdn / (channel + '.zip') with zipfile.ZipFile(archive, 'w') as package: package.writestr('steamrtarm64/steam', channel) @@ -68,12 +68,17 @@ cp "$TEST_CDN/${url##*/}" "$out" f'\t\t"file" "{archive.name}"\n\t\t"sha2" "{digest}"\n' '\t}\n}\n') - def install(self, fail_host=None): + def install(self, fail_host=None, *args, **extra): env = self.env if fail_host is None else dict(self.env, TEST_FAIL_HOST=fail_host) - return subprocess.run(['bash', str(SCRIPT)], env=env, text=True, capture_output=True) + return subprocess.run(['bash', str(SCRIPT), *args], env=dict(env, **extra), text=True, capture_output=True) - def assert_installed(self, channel='steamdeck_publicbeta', fail_host=None, expected=None): - result = self.install(fail_host) + def channel(self, **extra): + result = self.install(None, '--channel', **extra) + self.assertEqual(0, result.returncode, result.stderr) + return result.stdout.strip() + + def assert_installed(self, channel='steamdeck_publicbeta', fail_host=None, expected=None, **extra): + result = self.install(fail_host, **extra) self.assertEqual(0, result.returncode, result.stderr) self.assertEqual(channel + '\n', (self.steam / 'package/beta').read_text()) self.assertEqual('123456\n', (self.steam / 'package/droiddeck-installed').read_text()) @@ -87,6 +92,7 @@ cp "$TEST_CDN/${url##*/}" "$out" 'https://client-update.steamstatic.com/' + channel + '.zip', ] self.assertEqual(expected, self.requests.read_text().splitlines()) + return result def test_downloads_the_deck_beta_client(self): self.assert_installed() @@ -155,8 +161,80 @@ cp "$TEST_CDN/${url##*/}" "$out" self.requests.unlink() with (package / 'droiddeck-unconfirmed').open('a') as marker: marker.write('start\nstart\nstart\n') + self.assertEqual(0, self.install(BL_STEAM_CHANNEL='beta').returncode) + self.assertFalse(self.requests.exists()) + + def test_the_default_channel_is_the_deck_beta(self): + self.assertEqual('steamdeck_publicbeta', self.channel()) + + def stable_requests(self, beta_manifest=True): + stable = [ + 'https://client-update.steamstatic.com/steam_client_steamdeck_stable_linuxarm64', + 'https://client-update.steamstatic.com/steamdeck_stable.zip', + ] + if beta_manifest: + stable.insert(0, 'https://client-update.steamstatic.com/steam_client_steamdeck_publicbeta_linuxarm64') + return stable + + def test_a_repaired_beta_client_that_still_shows_nothing_falls_back_to_stable(self): + package = self.break_client(2) + self.assertEqual(0, self.install().returncode) + self.requests.unlink() + with (package / 'droiddeck-unconfirmed').open('a') as marker: + marker.write('start\nstart\n') + result = self.assert_installed('steamdeck_stable', expected=self.stable_requests(False)) + self.assertIn("falling back to Valve's steamdeck_stable Steam client", result.stdout) + self.assertEqual('steamdeck_stable\n', (package / 'droiddeck-channel').read_text()) + self.assertEqual('repaired\n', (package / 'droiddeck-unconfirmed').read_text()) + self.assertEqual('steamdeck_stable', self.channel()) + + def test_the_stable_fallback_is_not_repeated(self): + self.test_a_repaired_beta_client_that_still_shows_nothing_falls_back_to_stable() + self.requests.unlink() + with (self.steam / 'package/droiddeck-unconfirmed').open('a') as marker: + marker.write('start\nstart\nstart\n') self.assertEqual(0, self.install().returncode) self.assertFalse(self.requests.exists()) + self.assertEqual('steamdeck_stable\n', (self.steam / 'package/beta').read_text()) + + def test_a_pinned_beta_channel_never_falls_back(self): + package = self.break_client(2) + self.assertEqual(0, self.install().returncode) + with (package / 'droiddeck-unconfirmed').open('a') as marker: + marker.write('start\nstart\n') + self.assertEqual('steamdeck_publicbeta', self.channel(BL_STEAM_CHANNEL='beta')) + self.assertEqual(0, self.install(BL_STEAM_CHANNEL='beta').returncode) + self.assertFalse((package / 'droiddeck-channel').exists()) + + def test_the_channel_setting_selects_stable(self): + setting = self.home / '.config/droiddeck/steam-channel' + setting.parent.mkdir(parents=True) + setting.write_text('stable\n') + self.assertEqual('steamdeck_stable', self.channel()) + self.assert_installed('steamdeck_stable', expected=self.stable_requests(False)) + self.assertFalse((self.steam / 'package/droiddeck-channel').exists()) + + def empty_beta_manifest(self): + (self.cdn / 'steam_client_steamdeck_publicbeta_linuxarm64').write_text( + '"linuxarm64"\n{\n\t"version" "123457"\n' + '\t"client_steamrtarm64_linuxarm64"\n\t{\n' + '\t\t"file" "client.zip"\n\t\t"sha2" "00"\n' + '\t}\n}\n') + + def test_a_beta_manifest_without_an_arm64_client_falls_back_to_stable(self): + self.empty_beta_manifest() + result = self.assert_installed('steamdeck_stable', expected=self.stable_requests()) + self.assertIn('no client components in steam_client_steamdeck_publicbeta_linuxarm64', result.stderr) + self.assertIn('client_steamrtarm64_linuxarm64', result.stderr) + self.assertEqual('steamdeck_stable\n', (self.steam / 'package/droiddeck-channel').read_text()) + self.assertEqual('steamdeck_stable', self.channel()) + + def test_a_pinned_beta_manifest_without_an_arm64_client_fails_and_names_its_blocks(self): + self.empty_beta_manifest() + result = self.install(BL_STEAM_CHANNEL='beta') + self.assertNotEqual(0, result.returncode) + self.assertIn('client_steamrtarm64_linuxarm64', result.stderr) + self.assertFalse((self.steam / 'package/droiddeck-installed').exists()) def test_requested_repair_runs_once_and_is_retried_until_it_completes(self): package = self.break_client(0) diff --git a/tools/tests/test_steamrt_shim.py b/tools/tests/test_steamrt_shim.py new file mode 100644 index 000000000..54f105eeb --- /dev/null +++ b/tools/tests/test_steamrt_shim.py @@ -0,0 +1,117 @@ +import os +from pathlib import Path +import re +import subprocess +import tempfile +import unittest + + +BIN = Path(__file__).resolve().parents[1] / 'linuxfs/overlay/usr/local/bin' +SHIM = BIN / 'droiddeck-steamrt-shim' +SESSION = (BIN / 'droiddeck-session').read_text() +X64 = (BIN / 'droiddeck-steam-x64').read_text() + + +class SteamrtShimTest(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.client = self.root / 'steamrtarm64' + self.client.mkdir() + self.shim = self.root / 'shim' + + def runtime(self, path): + runtime = self.client / path + (runtime / 'bin').mkdir(parents=True) + (runtime / 'bin/steam-runtime-check-requirements').write_text('#!/bin/sh\nexit 1\n') + (runtime / 'bin/steam-runtime-system-info').write_text('#!/bin/sh\necho info\n') + (runtime / 'files').mkdir() + (runtime / 'files/marker').write_text('runtime files') + return runtime + + def build(self): + return subprocess.run(['bash', str(SHIM), str(self.client), str(self.shim)], + capture_output=True, text=True, timeout=30) + + def sh(self, *args): + return subprocess.run(['sh', *map(str, args)], capture_output=True, text=True, timeout=30) + + def assert_shim(self): + result = self.build() + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual(0, self.sh(self.shim / 'bin/steam-runtime-check-requirements').returncode) + self.assertEqual('info\n', self.sh(self.shim / 'bin/steam-runtime-system-info').stdout) + self.assertEqual('runtime files', (self.shim / 'files/marker').read_text()) + launched = self.sh(self.shim / 'bin/steam-runtime-launch-client', '--env=BL_SHIM_TEST=yes', + f'--directory={self.root}', '--', 'sh', '-c', 'echo "$BL_SHIM_TEST $(basename "$PWD")"') + self.assertEqual(f'yes {self.root.name}\n', launched.stdout) + + def test_a_client_without_a_container_gets_no_shim(self): + self.shim.mkdir() + self.assertEqual(1, self.build().returncode) + self.assertFalse(self.shim.exists()) + + def test_the_pressure_vessel_runtime_is_shimmed(self): + self.runtime('pv-runtime/steam-runtime-steamrt') + self.assert_shim() + + def test_a_requirements_probe_elsewhere_is_shimmed(self): + self.runtime('steam-runtime') + self.assert_shim() + + def test_the_arm64_session_starts_a_containerized_client_through_the_shim(self): + block = re.search(r'\n( +steam_client=\("\$steam_root/steamrtarm64/steam"\)\n.*?\n fi\n)', SESSION, re.S).group(1) + stub = self.root / 'droiddeck-steamrt-shim' + for status, shimmed in ((1, False), (0, True)): + stub.write_text(f'#!/bin/bash\nexit {status}\n') + stub.chmod(0o755) + script = 'if :; then\n' + block + 'printf "%s\\n" "${steam_client[@]}"\n' + env = dict(os.environ, HOME=str(self.root), steam_root='/steam', + PATH=str(self.root) + os.pathsep + os.environ['PATH']) + result = subprocess.run(['bash', '-c', script], env=env, capture_output=True, text=True, timeout=30) + self.assertEqual(0, result.returncode, result.stderr) + lines = result.stdout.splitlines() + self.assertEqual('/steam/steamrtarm64/steam', lines[-1]) + self.assertEqual(shimmed, 'Valve containerized the arm64 Steam client' in result.stdout) + self.assertEqual(shimmed, any(line.startswith('STEAM_RUNTIME_STEAMRT=') and + line.endswith('/.droiddeck-steamrt-shim') for line in lines)) + + def test_both_clients_are_told_they_already_run_inside_the_container(self): + guard = SESSION.index('export STEAM_STEAMRT_RECURSION_GUARD=1') + self.assertLess(guard, SESSION.index('"${steam_client[@]}" "${_bl_ui[@]}"')) + self.assertIn('-u STEAM_STEAMRT_RECURSION_GUARD', SESSION[:guard]) + self.assertIn('export STEAM_STEAMRT_RECURSION_GUARD=1', X64) + self.assertIn('droiddeck-steamrt-shim "$STEAM_X64/steamrt64" "$X64_HOME/steamrt-shim"', X64) + + +class SessionChannelTest(unittest.TestCase): + BLOCK = re.search(r'\n( +steam_channel=steamdeck_publicbeta\n.*?\n +fi\n)', SESSION, re.S).group(1) + + def channel(self, x64, installer): + with tempfile.TemporaryDirectory() as temp: + stub = Path(temp) / 'droiddeck-steam-install' + stub.write_text('#!/bin/bash\n' + installer + '\n') + stub.chmod(0o755) + env = dict(os.environ, steam_x64=x64, PATH=temp + os.pathsep + os.environ['PATH']) + result = subprocess.run(['bash', '-c', self.BLOCK + 'echo "$steam_channel"\n'], env=env, + capture_output=True, text=True, timeout=30) + self.assertEqual(0, result.returncode, result.stderr) + return result.stdout.strip() + + def test_the_arm64_client_follows_the_installed_channel(self): + self.assertEqual('steamdeck_stable', self.channel('0', '[ "$1" = --channel ] && echo steamdeck_stable')) + + def test_an_installer_without_an_answer_keeps_the_deck_beta(self): + self.assertEqual('steamdeck_publicbeta', self.channel('0', 'exit 1')) + + def test_the_x86_client_keeps_its_own_channel(self): + self.assertEqual('steamdeck_publicbeta', self.channel('1', 'echo steamdeck_stable')) + + def test_the_client_branch_on_disk_and_clientbeta_use_the_same_channel(self): + self.assertIn('-clientbeta "$steam_channel"', SESSION) + self.assertIn('echo "$steam_channel" > "$steam_beta"', SESSION) + + +if __name__ == '__main__': + unittest.main() From 2eb18f55403df99fed982d1b6e2ce101641609e2 Mon Sep 17 00:00:00 2001 From: ovurrsl Date: Fri, 23 Oct 2026 01:30:15 +0300 Subject: [PATCH 18/59] fix: document which Steam client builds Valve's arm64 manifests carry --- docs/development/arm64-steam-client.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/development/arm64-steam-client.md b/docs/development/arm64-steam-client.md index bb1efc04f..bb9120017 100644 --- a/docs/development/arm64-steam-client.md +++ b/docs/development/arm64-steam-client.md @@ -40,3 +40,20 @@ same treatment if Valve containerizes it too: For the arm64 client the session then starts it with `STEAM_RUNTIME_STEAMRT` and `PATH` pointing at `~/.droiddeck-steamrt-shim` and logs "Valve containerized the arm64 Steam client". Without those files the shim is not built and the start is unchanged. + +## What Valve's manifests carry (checked 2026-10-22) + +- `steam_client_steamdeck_publicbeta_linuxarm64` (1792626604) and `steam_client_steamdeck_stable_linuxarm64` + (1792618285) have the same blocks. Three platforms share them: + - `*_linuxarm64_linuxarm64`: the native client (`steamrtarm64/`, `linuxarm64/`). No pressure-vessel, no + `steam-runtime-check-requirements`. These are the only platform blocks the installer keeps. + - `*_steamrt_linuxarm64`: Valve's x86-64 SteamRT 3c client (`steamrt64/steam` is an x86-64 ELF) with + `steamrt64/pv-runtime/steam-runtime-steamrt` (pressure-vessel, `steam-runtime-check-requirements`, + `steam-runtime-launch-client`). This is the containerized client the 2026-10-15 beta made the default on + x86. Valve ships it to arm64 hosts as well, presumably to run under FEX. The installer skips it. + - `bins_androidarm64_linuxarm64`: `androidarm64/libsteamclient.so`, `steamservice.so` and friends. +- `steam_client_steamdeck_publicbeta_ubuntu12` has stopped at 1792108788, while stable `ubuntu12` follows + stable (1792618285). On beta the 32-bit `ubuntu12` client no longer gets updates. +- If a future manifest drops `*_linuxarm64_linuxarm64`, the installer logs the block list and moves to + stable. If stable drops it too, the remaining option is the x86-64 client under FEX + ([x86-64-steam-client.md](x86-64-steam-client.md)). From 70eec3cc32e326765f3a2dbe8f02ec53af9a4fd7 Mon Sep 17 00:00:00 2001 From: ovurrsl Date: Fri, 23 Oct 2026 01:32:15 +0300 Subject: [PATCH 19/59] fix: a half-built steamrt shim is removed, and its launcher accepts --directory DIR --- tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-x64 | 2 +- .../linuxfs/overlay/usr/local/bin/droiddeck-steamrt-shim | 9 +++++++++ tools/tests/test_steamrt_shim.py | 8 +++++++- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-x64 b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-x64 index df9ec941b..f6f11d187 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-x64 +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-x64 @@ -521,7 +521,7 @@ ln -sfn "$STEAM_X64/ubuntu12_64" "$X64_HOME/.steam/bin64" ln -sfn "$STEAM_X64/linux32" "$X64_HOME/.steam/sdk32" ln -sfn "$STEAM_X64/linux64" "$X64_HOME/.steam/sdk64" # Valve's Steam Runtime container needs user namespaces and a session-bus launcher; steamrt-shim stands in for both. -droiddeck-steamrt-shim "$STEAM_X64/steamrt64" "$X64_HOME/steamrt-shim" || true +droiddeck-steamrt-shim "$STEAM_X64/steamrt64" "$X64_HOME/steamrt-shim" || rm -rf "$X64_HOME/steamrt-shim" # Valve's platform libraries ship without soname links (pressure-vessel makes them); ldconfig adds them here. srt_libs=$X64_HOME/steamrt-libs rm -rf "$srt_libs" diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steamrt-shim b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steamrt-shim index 96e851bc9..feb4c5711 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steamrt-shim +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steamrt-shim @@ -15,6 +15,7 @@ if [ -z "$srt_real" ] || [ ! -d "$srt_real" ]; then exit 1 fi rm -rf "$shim" +trap 'rm -rf "$shim"' ERR mkdir -p "$shim/bin" for e in "$srt_real"/* "$srt_real"/.[!.]*; do [ -e "$e" ] || continue @@ -40,6 +41,14 @@ while [ $# -gt 0 ]; do --directory=*) cd "${1#--directory=}" || exit 127 ;; --env=*) export "${1#--env=}" ;; --unset-env=*) unset "${1#--unset-env=}" ;; + --directory|--env|--unset-env) + [ $# -ge 2 ] || exit 127 + case $1 in + --directory) cd "$2" || exit 127 ;; + --env) export "$2" ;; + --unset-env) unset "$2" ;; + esac + shift ;; esac shift done diff --git a/tools/tests/test_steamrt_shim.py b/tools/tests/test_steamrt_shim.py index 54f105eeb..05f7345fe 100644 --- a/tools/tests/test_steamrt_shim.py +++ b/tools/tests/test_steamrt_shim.py @@ -46,6 +46,12 @@ class SteamrtShimTest(unittest.TestCase): launched = self.sh(self.shim / 'bin/steam-runtime-launch-client', '--env=BL_SHIM_TEST=yes', f'--directory={self.root}', '--', 'sh', '-c', 'echo "$BL_SHIM_TEST $(basename "$PWD")"') self.assertEqual(f'yes {self.root.name}\n', launched.stdout) + launched = self.sh(self.shim / 'bin/steam-runtime-launch-client', '--env', 'BL_SHIM_TEST=split', + '--directory', self.root, '--', 'sh', '-c', 'echo "$BL_SHIM_TEST $(basename "$PWD")"') + self.assertEqual(f'split {self.root.name}\n', launched.stdout) + missing = self.sh(self.shim / 'bin/steam-runtime-launch-client', '--directory', self.root / 'missing', + '--', 'true') + self.assertEqual(127, missing.returncode) def test_a_client_without_a_container_gets_no_shim(self): self.shim.mkdir() @@ -82,7 +88,7 @@ class SteamrtShimTest(unittest.TestCase): self.assertLess(guard, SESSION.index('"${steam_client[@]}" "${_bl_ui[@]}"')) self.assertIn('-u STEAM_STEAMRT_RECURSION_GUARD', SESSION[:guard]) self.assertIn('export STEAM_STEAMRT_RECURSION_GUARD=1', X64) - self.assertIn('droiddeck-steamrt-shim "$STEAM_X64/steamrt64" "$X64_HOME/steamrt-shim"', X64) + self.assertIn('droiddeck-steamrt-shim "$STEAM_X64/steamrt64" "$X64_HOME/steamrt-shim" || rm -rf "$X64_HOME/steamrt-shim"', X64) class SessionChannelTest(unittest.TestCase): From 77dd04ec8c3fe7c2d3074e625dc62e5ec56de501 Mon Sep 17 00:00:00 2001 From: maxjivi05 Date: Fri, 23 Oct 2026 12:15:43 -0400 Subject: [PATCH 20/59] fix: droiddeck-ntsync keeps protection when more than four signals arrive and keeps waits intact when a handler waits again - A fifth distinct signal inside a critical section is now held pending with the thread's signal mask and released once the section ends, instead of running its handler while an object lock is held. - A wait from a signal handler during another wait's sleep uses its own queue and, for a second device, a reference for that call only, so the outer wait keeps its queue and can't return the inner wait's result. --- tools/linuxfs/preload/ntsync.c | 84 ++++++++++++++++++++++------- tools/tests/test_ntsync_preload.py | 87 ++++++++++++++++++++++++++++++ 2 files changed, 153 insertions(+), 18 deletions(-) diff --git a/tools/linuxfs/preload/ntsync.c b/tools/linuxfs/preload/ntsync.c index 2b1c79045..76e50972d 100644 --- a/tools/linuxfs/preload/ntsync.c +++ b/tools/linuxfs/preload/ntsync.c @@ -17,6 +17,7 @@ #include #include #include +#include #include struct ns_sem_args { @@ -170,6 +171,7 @@ struct ns_tls { struct ns_dev *d; uint32_t q; int waiting; + int sleep; }; static struct ns_dev ns_devs[NS_DEVS]; @@ -262,8 +264,10 @@ static void ns_unlock(uint32_t *w) { struct ns_cs { int depth; int count; + int nheld; int sigs[NS_DEFER]; siginfo_t info[NS_DEFER]; + sigset_t held; }; static __thread struct ns_cs ns_cs; @@ -290,8 +294,26 @@ static int ns_defer(int sig, const siginfo_t *si) { return 1; } +static int ns_park(int sig, const siginfo_t *si, ucontext_t *uc) { + sigset_t one; + sigemptyset(&one); + sigaddset(&one, sig); + int saved = errno; + pthread_sigmask(SIG_BLOCK, &one, NULL); + pid_t pid = getpid(), tid = (pid_t)ns_self(); + int ok = (si && syscall(SYS_rt_tgsigqueueinfo, pid, tid, sig, si) == 0) || syscall(SYS_tgkill, pid, tid, sig) == 0; + if (!ok) pthread_sigmask(SIG_UNBLOCK, &one, NULL); + errno = saved; + if (!ok) return 0; + sigaddset(&uc->uc_sigmask, sig); + if (!ns_cs.nheld) sigemptyset(&ns_cs.held); + sigaddset(&ns_cs.held, sig); + ns_cs.nheld = 1; + return 1; +} + static void ns_trampoline(int sig, siginfo_t *si, void *uc) { - if (__atomic_load_n(&ns_cs.depth, __ATOMIC_RELAXED) > 0 && ns_defer(sig, si)) return; + if (__atomic_load_n(&ns_cs.depth, __ATOMIC_RELAXED) > 0 && (ns_defer(sig, si) || (uc && ns_park(sig, si, uc)))) return; uintptr_t h = __atomic_load_n(&ns_handlers[sig], __ATOMIC_ACQUIRE); if (!h) return; if (__atomic_load_n(&ns_siginfo[sig], __ATOMIC_RELAXED)) ((void (*)(int, siginfo_t *, void *))h)(sig, si, uc); @@ -321,7 +343,19 @@ static void ns_restore(void) { __atomic_signal_fence(__ATOMIC_SEQ_CST); if (--ns_cs.depth == 0) { __atomic_signal_fence(__ATOMIC_SEQ_CST); + int nheld = ns_cs.nheld; + sigset_t held; + if (nheld) { + held = ns_cs.held; + ns_cs.nheld = 0; + __atomic_signal_fence(__ATOMIC_SEQ_CST); + } if (ns_cs.count) ns_flush(); + if (nheld) { + int saved = errno; + pthread_sigmask(SIG_UNBLOCK, &held, NULL); + errno = saved; + } } } @@ -875,6 +909,7 @@ static void ns_key_init(void) { static int ns_hold(struct ns_dev *d) { if (ns_tq.d == d) return 1; if (!ns_dev_get(d)) return 0; + if (ns_tq.sleep) return 2; struct ns_dev *old = ns_tq.d; if (old) { ns_block(); @@ -891,6 +926,22 @@ static int ns_hold(struct ns_dev *d) { return 1; } +static struct ns_q *ns_new_q(struct ns_dev *d, uint32_t *qi) { + struct ns_head *h = d->h; + uint32_t i; + if (!ns_pop(h, &h->q_free, 0, &i) && !ns_grow(&h->q_hwm, NS_QS, &i)) { + ns_sweep_dead(h); + if (!ns_pop(h, &h->q_free, 0, &i)) return NULL; + } + struct ns_q *q = ns_q(h, i); + q->proc = (uint32_t)d->proc; + q->waiting = 0; + q->sleeping = 0; + q->inuse = 1; + *qi = i; + return q; +} + static struct ns_q *ns_thread_q(struct ns_dev *d, uint32_t *qi) { struct ns_head *h = d->h; if (ns_tq.d != d) return NULL; @@ -902,19 +953,10 @@ static struct ns_q *ns_thread_q(struct ns_dev *d, uint32_t *qi) { *qi = ns_tq.q - 1; return ns_q(h, *qi); } - uint32_t i; - if (!ns_pop(h, &h->q_free, 0, &i) && !ns_grow(&h->q_hwm, NS_QS, &i)) { - ns_sweep_dead(h); - if (!ns_pop(h, &h->q_free, 0, &i)) return NULL; - } - struct ns_q *q = ns_q(h, i); - q->proc = (uint32_t)d->proc; - q->waiting = 0; - q->sleeping = 0; - q->inuse = 1; - ns_tq.q = i + 1; + struct ns_q *q = ns_new_q(d, qi); + if (!q) return NULL; + ns_tq.q = *qi + 1; ns_tq.waiting = 0; - *qi = i; return q; } @@ -1064,8 +1106,9 @@ static int ns_wait(struct ns_dev *d, struct ns_wait_args *ua, int all) { for (uint32_t j = 0; j < i; j++) if (slots[j] == slots[i]) return EINVAL; } + int nested = ns_tq.sleep > 0; ns_block(); - if (!all && !ns_tq.waiting) { + if (!all && (nested || !ns_tq.waiting)) { int dead = 0, got = ns_try_any(h, slots, gens, total, args.owner, &dead); if (got >= 0) { ns_restore(); @@ -1078,7 +1121,7 @@ static int ns_wait(struct ns_dev *d, struct ns_wait_args *ua, int all) { } } uint32_t qi; - struct ns_q *q = ns_ready(d) ? ns_thread_q(d, &qi) : NULL; + struct ns_q *q = !ns_ready(d) ? NULL : nested ? ns_new_q(d, &qi) : ns_thread_q(d, &qi); if (!q) { ns_restore(); return ENOMEM; @@ -1100,7 +1143,7 @@ static int ns_wait(struct ns_dev *d, struct ns_wait_args *ua, int all) { } q->total = total; q->waiting = 1; - ns_tq.waiting = 1; + if (!nested) ns_tq.waiting = 1; int bad = 0; if (!all) { for (uint32_t i = 0; i < total && !bad; i++) { @@ -1152,14 +1195,17 @@ static int ns_wait(struct ns_dev *d, struct ns_wait_args *ua, int all) { } int ret = 0; if (!bad && __atomic_load_n(&q->signaled, __ATOMIC_ACQUIRE) == -1) { + ns_tq.sleep++; ns_restore(); ret = ns_sleep(q, &args); ns_block(); + ns_tq.sleep--; } ns_drop_q(h, qi); - ns_tq.waiting = 0; + if (!nested) ns_tq.waiting = 0; int32_t got = __atomic_load_n(&q->signaled, __ATOMIC_ACQUIRE); int dead = (int)q->ownerdead; + if (nested) ns_free_q(h, qi); ns_restore(); if (bad) return EINVAL; if (got != -1) { @@ -1322,8 +1368,10 @@ int bl_ntsync_ioctl(int fd, unsigned long req, void *arg, int *rc) { uint64_t e = ns_lookup(fd); if (!e) return 0; struct ns_dev *d = &ns_devs[(e >> 56) & 7]; - if (!ns_hold(d)) return 0; + int held = ns_hold(d); + if (!held) return 0; int out = 0, err = (e & NS_E_DEVICE) ? ns_dev_ioctl(d, req, arg, &out) : ns_obj_ioctl(d, e, req, arg); + if (held == 2) ns_dev_put(d); if (err) { errno = err; *rc = -1; diff --git a/tools/tests/test_ntsync_preload.py b/tools/tests/test_ntsync_preload.py index e05178c8d..34f3eaea4 100644 --- a/tools/tests/test_ntsync_preload.py +++ b/tools/tests/test_ntsync_preload.py @@ -393,6 +393,91 @@ static void signals(void) { close(busy_obj); } +static volatile int burst_count; +static void bursting(int sig, siginfo_t *si, void *uc) { + (void)sig; (void)si; (void)uc; + uint32_t v = 0; + ioctl(busy_obj, NT_EVENT_SET, &v); + __atomic_add_fetch(&burst_count, 1, __ATOMIC_RELAXED); +} + +static void bursts(void) { + void *seen = NULL; + busy_obj = event(0, 0); + for (int s = 0; s < 8; s++) CHECK("install_burst", install_handler(SIGRTMIN + s, bursting, &seen) == 0); + pthread_t w; + stop_worker = 0; + burst_count = 0; + pthread_create(&w, NULL, worker, NULL); + union sigval val = {0}; + int sent = 0; + for (int i = 0; i < 400; i++) { + for (int s = 0; s < 8; s++) + if (pthread_sigqueue(w, SIGRTMIN + s, val) == 0) sent++; + usleep(200); + } + for (int i = 0; i < 2000 && __atomic_load_n(&burst_count, __ATOMIC_RELAXED) < sent; i++) usleep(1000); + stop_worker = 1; + pthread_join(w, NULL); + CHECK("burst_all_handled", burst_count == sent && sent > 0); + close(busy_obj); +} + +static int outer_obj, inner_obj, other_dev, other_obj; +static volatile int nested_ok, nested_done, outer_done; +static void nesting(int sig, siginfo_t *si, void *uc) { + (void)sig; (void)si; (void)uc; + uint32_t idx = 99; + int ok = wait_on(NT_WAIT_ANY, &inner_obj, 1, 21, 0, mono_ns(50), &idx) == 0 && idx == 0; + struct wait_args w = {mono_ns(2), (uintptr_t)&other_obj, 1, 0xdead, 0, 22, 0, 0}; + ok = ok && ioctl(other_dev, NT_WAIT_ANY, &w) < 0 && errno == ETIMEDOUT; + nested_ok += ok; + __atomic_store_n(&nested_done, 1, __ATOMIC_RELEASE); +} +static void *outer_waiter(void *p) { + struct wait_args w = {mono_ns(3000), (uintptr_t)&outer_obj, 1, 99, 0, 23, 0, 0}; + int r; + do { r = ioctl(dev, NT_WAIT_ANY, &w); } while (r < 0 && errno == EINTR); + *(int *)p = r == 0 && w.index == 0; + __atomic_store_n(&outer_done, 1, __ATOMIC_RELEASE); + return NULL; +} + +static void nested_waits(void) { + void *seen = NULL; + other_dev = open("/dev/ntsync", O_RDWR | O_CLOEXEC); + CHECK("second_device", other_dev >= 0); + struct event_args ea = {0, 0}; + other_obj = ioctl(other_dev, NT_CREATE_EVENT, &ea); + inner_obj = event(1, 1); + CHECK("install_nesting", install_handler(SIGUSR2, nesting, &seen) == 0); + int good = 0, early = 0; + nested_ok = 0; + for (int i = 0; i < 40; i++) { + outer_obj = event(0, 0); + int result = 0; + pthread_t t; + nested_done = outer_done = 0; + pthread_create(&t, NULL, outer_waiter, &result); + usleep(5000); + pthread_kill(t, SIGUSR2); + while (!__atomic_load_n(&nested_done, __ATOMIC_ACQUIRE)) usleep(200); + usleep(5000); + early += __atomic_load_n(&outer_done, __ATOMIC_ACQUIRE); + uint32_t v = 0; + ioctl(outer_obj, NT_EVENT_SET, &v); + pthread_join(t, NULL); + good += result; + close(outer_obj); + } + CHECK("outer_wait_not_woken_by_nested_wait", early == 0); + CHECK("outer_wait_survives_nested_waits", good == 40); + CHECK("nested_waits_complete", nested_ok == 40); + close(inner_obj); + close(other_obj); + close(other_dev); +} + int main(void) { alarm(60); dev = open("/dev/ntsync", O_RDWR | O_CLOEXEC); @@ -409,6 +494,8 @@ int main(void) { cross_process(); stress(); signals(); + bursts(); + nested_waits(); printf("done fails=%d\n", fails); return fails != 0; } From 6b6d7d2ac777bd8190199f7d0bcd63cc09b19a8b Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:13:19 -0400 Subject: [PATCH 21/59] feat: lift paused frame into a grey card with a glass resume button --- .../com/droiddeck/launcher/SessionActivity.kt | 45 ++- .../launcher/session/SessionService.kt | 11 + .../launcher/session/SessionState.kt | 4 + .../droiddeck/launcher/ui/SessionLoading.kt | 35 --- .../launcher/ui/SessionPausedOverlay.kt | 257 ++++++++++++++++++ 5 files changed, 316 insertions(+), 36 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/SessionPausedOverlay.kt diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 6de07a00d..c416e4f3a 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -329,6 +329,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private var glassesSettings by mutableStateOf(Glasses.settings.value) private var glassesScreen: GlassesScreen? = null private var glassesOutputActive by mutableStateOf(false) + private var pausedFrame by mutableStateOf(null) private var trackingRecovery by mutableStateOf(null) private var glasses3dForVr = false private var ended = false @@ -1013,9 +1014,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { SessionAchievements(inFront.game, inFront.title, onClose = ::closeAchievements) } com.droiddeck.launcher.ui.AchievementNotificationOverlay() - if (SessionState.suspended) SessionPausedOverlay( + SessionPausedOverlay( + suspended = SessionState.suspended, + engaged = SessionState.resumeRequested || SessionState.resuming, + failures = SessionState.resumeFailures, + frame = pausedFrame, title = pausedTitle(), onResume = { SessionService.resume(this@SessionActivity) }, + onHidden = { pausedFrame = null }, ) if (leaving) com.droiddeck.launcher.ui.FloodSpread( com.droiddeck.launcher.ui.LocalPalette.current.signal, @@ -2860,6 +2866,40 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { if (down || code !in gestureKeysDown) WaylandCompositor.nativeSendKey(code, if (down) 1 else 0) } + // The frozen game cannot draw again, so the paused overlay shows a copy taken while the surface still holds its frame. + private fun capturePausedFrame() { + if (!SessionState.running || pipUi || isInPictureInPictureMode || glassesOutputActive) return + if (suspendPolicy == SessionPrefs.SUSPEND_NEVER || !::surfaceView.isInitialized) return + if (surfaceView.holder.surface?.isValid != true) return + val width = surfaceView.width + val height = surfaceView.height + if (width <= 0 || height <= 0) return + val scale = minOf(1f, PAUSED_FRAME_MAX_EDGE / maxOf(width, height).toFloat()) + val bitmap = try { + android.graphics.Bitmap.createBitmap( + maxOf(1, (width * scale).toInt()), maxOf(1, (height * scale).toInt()), android.graphics.Bitmap.Config.ARGB_8888, + ) + } catch (_: OutOfMemoryError) { + return + } + val surfaceAt = IntArray(2).also { surfaceView.getLocationInWindow(it) } + val overlayAt = IntArray(2).also { sessionOverlay.getLocationInWindow(it) } + val left = surfaceAt[0] - overlayAt[0] + val top = surfaceAt[1] - overlayAt[1] + val bounds = android.graphics.Rect(left, top, left + width, top + height) + try { + android.view.PixelCopy.request(surfaceView, bitmap, { result -> + if (result == android.view.PixelCopy.SUCCESS && !isDestroyed) { + pausedFrame = com.droiddeck.launcher.ui.PausedFrame(bitmap.asImageBitmap(), bounds) + } else { + bitmap.recycle() + } + }, uiHandler) + } catch (_: IllegalArgumentException) { + bitmap.recycle() + } + } + private fun pipBounds(): android.graphics.Rect { val rect = android.graphics.Rect() if (!::surfaceView.isInitialized || !surfaceView.getGlobalVisibleRect(rect)) return rect @@ -2990,6 +3030,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } applyFollowOrientation(resources.configuration) if (runtimeRemovalBlocked) return + if (!SessionState.suspended) pausedFrame = null com.droiddeck.launcher.ui.Motion.refresh(this) refreshHomeApp() if (intent?.action == SessionService.ACTION_RESUME) { @@ -3015,6 +3056,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { override fun onPause() { if (runtimeRemovalBlocked) { super.onPause(); return } + capturePausedFrame() sessionClipboard.stop() if (::touchscreenGestures.isInitialized) touchscreenGestures.cancel() if (::touchpad.isInitialized) touchpad.cancel() @@ -3512,6 +3554,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { const val EXTRA_STEAMVR = "steamVr" const val EXTRA_REPAIR_RUNTIME = "repairRuntime" private const val LEAVE_TIMEOUT_MS = 1_500L + private const val PAUSED_FRAME_MAX_EDGE = 1920f private const val TAG = "SessionActivity" private var startedScreens = 0 private const val FEX_MISSING = "droiddeck-fex: x86 Linux programs need" 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 31c63ff46..cfead3d70 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -328,6 +328,8 @@ class SessionService : Service() { suspendAttemptFailed = false suspendController = null SessionState.suspended = false + SessionState.resumeRequested = false + SessionState.resuming = false SessionState.pipActive = false pipTask = false SessionState.program = intent?.getStringExtra(EXTRA_PROGRAM) @@ -1210,6 +1212,7 @@ class SessionService : Service() { } private fun resumeSession(panelVisible: Boolean = true) { + if (SessionState.suspended) SessionState.resumeRequested = true if (panelVisible) activityVisible = true screenOn = readScreenOn() manualPauseRequested = false @@ -1436,6 +1439,7 @@ class SessionService : Service() { freezeCancelled = true controller.cancelFreeze() } + if (shouldSuspend && SessionState.suspended && !suspendOperationPending) SessionState.resumeRequested = false if (suspendOperationPending || suspendAttemptFailed || shouldSuspend == SessionState.suspended) return val gen = sessionGen suspendOperationPending = true @@ -1477,16 +1481,20 @@ class SessionService : Service() { } } else { acquireLocks() + SessionState.resuming = true controller.resume { success -> mainHandler.post { if (gen != sessionGen || !SessionState.running) return@post suspendOperationPending = false + SessionState.resuming = false + SessionState.resumeRequested = false if (success) { SessionState.suspended = false SessionEvents.transition(SessionEvents.resumePhase(), "session.resumed") refreshNotification() } else { suspendAttemptFailed = true + SessionState.resumeFailures++ Log.w(TAG, "could not confirm that the session resumed") } updateSuspendPolicy() @@ -1576,6 +1584,8 @@ class SessionService : Service() { return@post } SessionState.suspended = false + SessionState.resumeRequested = false + SessionState.resuming = false SessionState.pipActive = false pipTask = false SessionState.guestPid = -1 @@ -2022,6 +2032,7 @@ class SessionService : Service() { fun resume(context: Context) { if (!SessionState.running) return + if (SessionState.suspended) SessionState.resumeRequested = true context.startService(Intent(context, SessionService::class.java).setAction(ACTION_RESUME)) } diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt index cebc33caa..03741cd0d 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt @@ -2,6 +2,7 @@ package com.droiddeck.launcher.session import java.io.File import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -46,6 +47,9 @@ object SessionState { @Volatile var runtimeDriver: String? = null var suspended by mutableStateOf(false) + var resumeRequested by mutableStateOf(false) + var resuming by mutableStateOf(false) + var resumeFailures by mutableIntStateOf(0) @Volatile var pipActive = false @Volatile var program: String? = null diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SessionLoading.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionLoading.kt index 4a0adea2d..d1c478eb0 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionLoading.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionLoading.kt @@ -24,7 +24,6 @@ import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.tween import androidx.compose.material.icons.Icons import androidx.compose.foundation.layout.heightIn -import androidx.compose.material.icons.filled.Pause import androidx.compose.foundation.rememberScrollState import androidx.compose.material3.Icon import androidx.compose.foundation.Canvas @@ -345,37 +344,3 @@ private fun CancelHint(onCancel: () -> Unit, modifier: Modifier = Modifier) { Text(stringResource(R.string.common_cancel), fontSize = 13.sp, color = colors.onSurfaceVariant) } } - -@Composable -fun SessionPausedOverlay(title: String = stringResource(R.string.load_steam_paused), onResume: () -> Unit) { - val colors = MaterialTheme.colorScheme - val pal = LocalPalette.current - val resumeFocus = remember { FocusRequester() } - LaunchedEffect(Unit) { - androidx.compose.runtime.withFrameNanos { } - runCatching { resumeFocus.requestFocus() } - } - Box( - modifier = Modifier - .fillMaxSize() - .background(pal.background.copy(alpha = 0.94f)) - .blockTouches(), - contentAlignment = Alignment.Center, - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(10.dp), - modifier = Modifier.widthIn(max = 440.dp).padding(32.dp), - ) { - Box(contentAlignment = Alignment.Center, modifier = Modifier.padding(bottom = 6.dp).size(64.dp).border(2.dp, pal.line2, CircleShape)) { - Icon(Icons.Filled.Pause, contentDescription = null, tint = colors.onBackground, modifier = Modifier.size(30.dp)) - } - Text(title, fontSize = 28.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, textAlign = TextAlign.Center) - PrimaryButton( - stringResource(R.string.resume_session), - modifier = Modifier.padding(top = 14.dp).focusRequester(resumeFocus).controllerConfirm(onClick = onResume), - onClick = onResume, - ) - } - } -} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SessionPausedOverlay.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionPausedOverlay.kt new file mode 100644 index 000000000..691c25bd9 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionPausedOverlay.kt @@ -0,0 +1,257 @@ +package com.droiddeck.launcher.ui + +import android.os.Build +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.FastOutLinearInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.keyframes +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.ColorMatrix +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.rotate +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import com.droiddeck.launcher.R +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch + +/** The last game frame, captured as the session left the screen, and where it sat in the overlay. */ +class PausedFrame(val image: ImageBitmap, val bounds: android.graphics.Rect) + +private const val CARD_SCALE = 0.82f +private val PopEasing = CubicBezierEasing(0.3f, 1.5f, 0.4f, 1f) + +private fun tone(saturation: Float, brightness: Float): ColorMatrix = + ColorMatrix().apply { + setToSaturation(saturation) + timesAssign(ColorMatrix().apply { setToScale(brightness, brightness, brightness, 1f) }) + } + +@Composable +fun SessionPausedOverlay( + suspended: Boolean, + engaged: Boolean, + failures: Int, + frame: PausedFrame?, + title: String, + onResume: () -> Unit, + onHidden: () -> Unit, +) { + var shown by remember { mutableStateOf(suspended) } + val lift = remember { Animatable(if (suspended) 1f else 0f) } + val orb = remember { Animatable(if (suspended) 1f else 0f) } + val press = remember { Animatable(1f) } + val ring = remember { Animatable(0f) } + val shake = remember { Animatable(0f) } + + LaunchedEffect(suspended, engaged) { + when { + suspended && !engaged -> { + shown = true + launch { press.animateTo(1f, Motion.sp(0.5f, Spring.StiffnessMedium)) } + launch { ring.animateTo(0f, Motion.tw(200)) } + launch { lift.animateTo(1f, Motion.tw(520)) } + orb.animateTo(1f, Motion.tw(380, delay = if (orb.value < 0.5f) 260 else 0, easing = PopEasing)) + } + suspended -> { + shown = true + launch { orb.animateTo(1f, Motion.tw(200)) } + launch { lift.animateTo(1f, Motion.tw(300)) } + launch { press.animateTo(0.92f, Motion.tw(140)) } + ring.animateTo(0.25f, Motion.tw(140)) + ring.animateTo(0.8f, Motion.tw(650, easing = CubicBezierEasing(0.1f, 0.6f, 0.3f, 1f))) + ring.animateTo(0.92f, Motion.tw(2000, easing = LinearOutSlowInEasing)) + } + shown -> { + ring.animateTo(1f, Motion.tw(110)) + coroutineScope { + launch { orb.animateTo(0f, Motion.tw(220, easing = FastOutLinearInEasing)) } + lift.animateTo(0f, Motion.sp(0.62f, 380f)) + } + shown = false + ring.snapTo(0f) + press.snapTo(1f) + onHidden() + } + } + } + + val seenFailures = remember { mutableIntStateOf(failures) } + LaunchedEffect(failures) { + if (failures == seenFailures.intValue) return@LaunchedEffect + seenFailures.intValue = failures + if (!shown || Motion.scale == 0f) return@LaunchedEffect + shake.animateTo(0f, keyframes { + durationMillis = Motion.ms(360) + 10f at Motion.ms(60) + -8f at Motion.ms(140) + 5f at Motion.ms(220) + -2f at Motion.ms(300) + }) + } + + if (!shown) return + + val presence = lift.value.coerceIn(0f, 1f) + val idle = suspended && !engaged + val focus = remember { FocusRequester() } + LaunchedEffect(Unit) { + withFrameNanos { } + runCatching { focus.requestFocus() } + } + val resumeLabel = stringResource(R.string.resume_session) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxSize() + .semantics { contentDescription = title } + .focusRequester(focus) + .controllerConfirm(enabled = idle, onClick = onResume) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + role = Role.Button, + onClickLabel = resumeLabel, + ) { if (idle) onResume() }, + ) { + if (frame != null) { + PausedCard(frame, lift.value, presence) + } else { + Box(Modifier.fillMaxSize().graphicsLayer { alpha = presence }.background(Color.Black.copy(alpha = 0.55f))) + } + ResumeOrb(orb.value, press.value, ring.value, shake.value, idle) + } +} + +@Composable +private fun BoxScope.PausedCard(frame: PausedFrame, lift: Float, presence: Float) { + val density = LocalDensity.current + val bounds = frame.bounds + val ambient = remember { ColorFilter.colorMatrix(tone(0.1f, 0.3f)) } + Box(Modifier.fillMaxSize().graphicsLayer { alpha = presence }.background(Color.Black)) { + Image( + frame.image, contentDescription = null, contentScale = ContentScale.Crop, colorFilter = ambient, + modifier = Modifier.fillMaxSize().then(if (Build.VERSION.SDK_INT >= 31) Modifier.blur(32.dp) else Modifier), + ) + } + Image( + frame.image, contentDescription = null, contentScale = ContentScale.FillBounds, + colorFilter = ColorFilter.colorMatrix(tone(1f - 0.92f * presence, 1f - 0.45f * presence)), + modifier = Modifier + .align(Alignment.TopStart) + .offset { IntOffset(bounds.left, bounds.top) } + .size(with(density) { bounds.width().toDp() }, with(density) { bounds.height().toDp() }) + .graphicsLayer { + val scale = 1f - (1f - CARD_SCALE) * lift + scaleX = scale + scaleY = scale + shape = RoundedCornerShape((20f * presence).dp) + clip = true + shadowElevation = 24.dp.toPx() * presence + }, + ) +} + +@Composable +private fun ResumeOrb(presence: Float, press: Float, ring: Float, shake: Float, idle: Boolean) { + val glintAlpha by animateFloatAsState(if (idle) 1f else 0f, Motion.tw(160), label = "glintAlpha") + val glint: State? = if (Motion.scale > 0f) { + rememberInfiniteTransition(label = "glint").animateFloat( + 0f, 360f, infiniteRepeatable(tween(Motion.ms(3600), easing = LinearEasing)), label = "glintAngle", + ) + } else null + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(96.dp) + .graphicsLayer { + val scale = press * (0.4f + 0.6f * presence) + scaleX = scale + scaleY = scale + alpha = presence.coerceIn(0f, 1f) + translationX = shake.dp.toPx() + } + .shadow(16.dp, CircleShape, clip = false) + .clip(CircleShape) + .background(Color.White.copy(alpha = 0.08f)) + .drawWithContent { + drawContent() + val stroke = 2.5.dp.toPx() + val radius = size.minDimension / 2f * 0.92f + if (glint != null && glintAlpha > 0f) { + rotate(glint.value) { + drawCircle( + Brush.sweepGradient( + 0f to Color.Transparent, + 0.083f to Color.White.copy(alpha = 0.4f * glintAlpha), + 0.166f to Color.Transparent, + 1f to Color.Transparent, + ), + radius = radius, style = Stroke(stroke), + ) + } + } + if (ring > 0f) { + drawArc( + Color.White, startAngle = -90f, sweepAngle = 360f * ring.coerceAtMost(1f), useCenter = false, + topLeft = Offset(center.x - radius, center.y - radius), size = Size(radius * 2f, radius * 2f), + style = Stroke(stroke, cap = StrokeCap.Round), + ) + } + }, + ) { + Icon(Icons.Rounded.PlayArrow, contentDescription = null, tint = Color.White, modifier = Modifier.size(48.dp)) + } +} From f683ca1dea50810e0e0f18e1265eae12bb105c4d Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:06:42 -0400 Subject: [PATCH 22/59] fix: show the paused card already settled, skip it on auto resume, and cancel manual pauses on a quick return --- .../com/droiddeck/launcher/SessionActivity.kt | 25 +++++++++++-- .../launcher/session/SessionService.kt | 8 +++++ .../launcher/session/SessionState.kt | 1 + .../launcher/ui/SessionPausedOverlay.kt | 35 ++++++++++++++----- 4 files changed, 59 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index c416e4f3a..b4fd3fd4b 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -330,6 +330,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private var glassesScreen: GlassesScreen? = null private var glassesOutputActive by mutableStateOf(false) private var pausedFrame by mutableStateOf(null) + private var pausedFrameWanted = false private var trackingRecovery by mutableStateOf(null) private var glasses3dForVr = false private var ended = false @@ -1014,14 +1015,20 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { SessionAchievements(inFront.game, inFront.title, onClose = ::closeAchievements) } com.droiddeck.launcher.ui.AchievementNotificationOverlay() + // Auto and Native resume on their own as the session comes back; only a failed resume needs the button. + val autoResume = suspendPolicy == SessionPrefs.SUSPEND_AUTO || suspendPolicy == SessionPrefs.SUSPEND_NATIVE SessionPausedOverlay( - suspended = SessionState.suspended, + suspended = SessionState.suspended && (!autoResume || SessionState.resumeFailed), engaged = SessionState.resumeRequested || SessionState.resuming, failures = SessionState.resumeFailures, frame = pausedFrame, title = pausedTitle(), onResume = { SessionService.resume(this@SessionActivity) }, - onHidden = { pausedFrame = null }, + onFrameNeeded = ::requestPausedFrame, + onHidden = { + pausedFrame = null + pausedFrameWanted = false + }, ) if (leaving) com.droiddeck.launcher.ui.FloodSpread( com.droiddeck.launcher.ui.LocalPalette.current.signal, @@ -2869,6 +2876,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { // The frozen game cannot draw again, so the paused overlay shows a copy taken while the surface still holds its frame. private fun capturePausedFrame() { if (!SessionState.running || pipUi || isInPictureInPictureMode || glassesOutputActive) return + if (SessionState.suspended && pausedFrame != null) return if (suspendPolicy == SessionPrefs.SUSPEND_NEVER || !::surfaceView.isInitialized) return if (surfaceView.holder.surface?.isValid != true) return val width = surfaceView.width @@ -2891,6 +2899,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { android.view.PixelCopy.request(surfaceView, bitmap, { result -> if (result == android.view.PixelCopy.SUCCESS && !isDestroyed) { pausedFrame = com.droiddeck.launcher.ui.PausedFrame(bitmap.asImageBitmap(), bounds) + pausedFrameWanted = false } else { bitmap.recycle() } @@ -2900,6 +2909,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } } + // Sessions that froze from picture-in-picture, or before onPause could copy a frame, have none; the + // compositor shows the frozen frame again once its output is back, so copy it from there. + private fun requestPausedFrame() { + if (pausedFrame != null) return + pausedFrameWanted = true + if (resumed && surfaceW > 0) capturePausedFrame() + } + private fun pipBounds(): android.graphics.Rect { val rect = android.graphics.Rect() if (!::surfaceView.isInitialized || !surfaceView.getGlobalVisibleRect(rect)) return rect @@ -3295,6 +3312,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { surfaceView.visibility = if (glassesOutputActive) View.INVISIBLE else View.VISIBLE Log.i(TAG, "compositor output attached: " + if (glassesOutputActive) "glasses display ${glassesScreen?.display?.displayId}" else "phone") + if (pausedFrameWanted) uiHandler.postDelayed({ + if (pausedFrameWanted && pausedFrame == null && resumed) capturePausedFrame() + }, PAUSED_FRAME_REDRAW_MS) } private fun restorePhoneOutput(screen: GlassesScreen) { @@ -3555,6 +3575,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { const val EXTRA_REPAIR_RUNTIME = "repairRuntime" private const val LEAVE_TIMEOUT_MS = 1_500L private const val PAUSED_FRAME_MAX_EDGE = 1920f + private const val PAUSED_FRAME_REDRAW_MS = 150L private const val TAG = "SessionActivity" private var startedScreens = 0 private const val FEX_MISSING = "droiddeck-fex: x86 Linux programs need" 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 cfead3d70..3db04cabc 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -330,6 +330,7 @@ class SessionService : Service() { SessionState.suspended = false SessionState.resumeRequested = false SessionState.resuming = false + SessionState.resumeFailed = false SessionState.pipActive = false pipTask = false SessionState.program = intent?.getStringExtra(EXTRA_PROGRAM) @@ -1414,6 +1415,9 @@ class SessionService : Service() { suspendPolicy != SessionPrefs.SUSPEND_NEVER && hidden && downloadActive && !suspendDownloadNow if (suspendPolicy == SessionPrefs.SUSPEND_MANUAL && hidden && !downloadsBlockingSuspend) { manualPauseRequested = true + } else if (!hidden && !SessionState.suspended) { + // Coming back before the freeze finished keeps the game running instead of pausing it a moment later. + manualPauseRequested = false } val controller = suspendController ?: return if (downloadsBlockingSuspend) acquireLocks() @@ -1463,6 +1467,7 @@ class SessionService : Service() { freezePending = false if (success) { SessionState.suspended = true + SessionState.resumeFailed = false SessionEvents.transition(SessionPhase.SUSPENDED, "session.suspended") releaseLocks() refreshNotification() @@ -1490,11 +1495,13 @@ class SessionService : Service() { SessionState.resumeRequested = false if (success) { SessionState.suspended = false + SessionState.resumeFailed = false SessionEvents.transition(SessionEvents.resumePhase(), "session.resumed") refreshNotification() } else { suspendAttemptFailed = true SessionState.resumeFailures++ + SessionState.resumeFailed = true Log.w(TAG, "could not confirm that the session resumed") } updateSuspendPolicy() @@ -1586,6 +1593,7 @@ class SessionService : Service() { SessionState.suspended = false SessionState.resumeRequested = false SessionState.resuming = false + SessionState.resumeFailed = false SessionState.pipActive = false pipTask = false SessionState.guestPid = -1 diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt index 03741cd0d..5ff739e47 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt @@ -50,6 +50,7 @@ object SessionState { var resumeRequested by mutableStateOf(false) var resuming by mutableStateOf(false) var resumeFailures by mutableIntStateOf(0) + var resumeFailed by mutableStateOf(false) @Volatile var pipActive = false @Volatile var program: String? = null diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SessionPausedOverlay.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionPausedOverlay.kt index 691c25bd9..b7f5cbfeb 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionPausedOverlay.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionPausedOverlay.kt @@ -34,6 +34,8 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.setValue import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment @@ -41,7 +43,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawWithContent -import androidx.compose.ui.draw.shadow +import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.geometry.Offset @@ -65,13 +67,15 @@ import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import com.droiddeck.launcher.R import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull /** The last game frame, captured as the session left the screen, and where it sat in the overlay. */ class PausedFrame(val image: ImageBitmap, val bounds: android.graphics.Rect) private const val CARD_SCALE = 0.82f -private val PopEasing = CubicBezierEasing(0.3f, 1.5f, 0.4f, 1f) +private const val FRAME_WAIT_MS = 600L private fun tone(saturation: Float, brightness: Float): ColorMatrix = ColorMatrix().apply { @@ -87,8 +91,10 @@ fun SessionPausedOverlay( frame: PausedFrame?, title: String, onResume: () -> Unit, + onFrameNeeded: () -> Unit, onHidden: () -> Unit, ) { + val currentFrame by rememberUpdatedState(frame) var shown by remember { mutableStateOf(suspended) } val lift = remember { Animatable(if (suspended) 1f else 0f) } val orb = remember { Animatable(if (suspended) 1f else 0f) } @@ -99,16 +105,26 @@ fun SessionPausedOverlay( LaunchedEffect(suspended, engaged) { when { suspended && !engaged -> { - shown = true + if (!shown) { + if (currentFrame == null) { + onFrameNeeded() + withTimeoutOrNull(FRAME_WAIT_MS) { snapshotFlow { currentFrame }.first { it != null } } + } + // Pausing mostly happens while the screen is off or the app is away, so it appears already settled. + press.snapTo(1f) + ring.snapTo(0f) + lift.snapTo(1f) + orb.snapTo(1f) + shown = true + return@LaunchedEffect + } launch { press.animateTo(1f, Motion.sp(0.5f, Spring.StiffnessMedium)) } launch { ring.animateTo(0f, Motion.tw(200)) } - launch { lift.animateTo(1f, Motion.tw(520)) } - orb.animateTo(1f, Motion.tw(380, delay = if (orb.value < 0.5f) 260 else 0, easing = PopEasing)) + orb.animateTo(1f, Motion.tw(200)) } suspended -> { - shown = true + if (!shown) return@LaunchedEffect launch { orb.animateTo(1f, Motion.tw(200)) } - launch { lift.animateTo(1f, Motion.tw(300)) } launch { press.animateTo(0.92f, Motion.tw(140)) } ring.animateTo(0.25f, Motion.tw(140)) ring.animateTo(0.8f, Motion.tw(650, easing = CubicBezierEasing(0.1f, 0.6f, 0.3f, 1f))) @@ -223,7 +239,10 @@ private fun ResumeOrb(presence: Float, press: Float, ring: Float, shake: Float, alpha = presence.coerceIn(0f, 1f) translationX = shake.dp.toPx() } - .shadow(16.dp, CircleShape, clip = false) + .drawBehind { + val halo = size.minDimension * 0.8f + drawCircle(Brush.radialGradient(0.6f to Color.Black.copy(alpha = 0.35f), 1f to Color.Transparent, radius = halo), radius = halo) + } .clip(CircleShape) .background(Color.White.copy(alpha = 0.08f)) .drawWithContent { From f88227ed74793f168018179ba9a442b97beccd43 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:47:29 -0400 Subject: [PATCH 23/59] feat: toggle Steam shader pre-caching from DroidDeck's Steam settings --- .../droiddeck/launcher/FrontEndActivity.kt | 4 + .../launcher/session/SessionFiles.kt | 1 + .../launcher/session/SessionPrefs.kt | 18 ++++ .../launcher/session/SessionService.kt | 1 + .../launcher/storage/SteamStorage.kt | 3 + .../launcher/ui/ModeSettingsDialog.kt | 8 ++ app/src/main/res/values/strings.xml | 2 + .../launcher/storage/SteamStorageTest.kt | 16 ++++ .../overlay/usr/local/bin/droiddeck-session | 4 + .../usr/local/bin/droiddeck-steam-shaders | 61 +++++++++++++ .../usr/local/bin/droiddeck-steam-ui-scale | 15 ++-- tools/tests/test_steam_shaders.py | 88 +++++++++++++++++++ 12 files changed, 216 insertions(+), 5 deletions(-) create mode 100755 tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-shaders create mode 100644 tools/tests/test_steam_shaders.py diff --git a/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt b/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt index 1081cb894..8ff9a1a48 100644 --- a/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt @@ -120,6 +120,7 @@ class FrontEndActivity : ComponentActivity() { private var glThread by mutableStateOf(true) private var noGlError by mutableStateOf(true) private var returnAfterGame by mutableStateOf(true) + private var shaderPrecache by mutableStateOf(true) private var steamRepairQueued by mutableStateOf(false) private var mangoapp by mutableStateOf(true) private var steamController by mutableStateOf(SessionPrefs.CONTROLLER_DECK) @@ -1634,6 +1635,7 @@ class FrontEndActivity : ComponentActivity() { syncBackend = if (mode == SessionService.MODE_STEAM) SessionPrefs.syncBackendOf(fastSync, fsyncFirst, syncFallback) else null, steamClientArch = if (mode == SessionService.MODE_STEAM) steamClientArch else null, returnAfterGame = if (mode == SessionService.MODE_STEAM) returnAfterGame else null, + shaderPrecache = if (mode == SessionService.MODE_STEAM) shaderPrecache else null, steamRepairQueued = steamRepairQueued, mangoapp = mangoapp, steamController = if (mode == SessionService.MODE_STEAM) steamController else null, @@ -1723,6 +1725,7 @@ class FrontEndActivity : ComponentActivity() { onSteamClientArch = { id -> SessionPrefs.setSteamClientArch(this, id); steamClientArch = id; refresh() }, onMangoapp = { on -> SessionPrefs.setMangoapp(this, on); mangoapp = on }, onReturnAfterGame = { on -> SessionPrefs.setReturnAfterGame(this, on); returnAfterGame = on }, + onShaderPrecache = { on -> SessionPrefs.setShaderPrecache(this, on); shaderPrecache = on }, onSteamController = { id -> SessionPrefs.setSteamController(this, id); steamController = id }, onRunSteamAtStartup = { on -> SessionPrefs.setRunSteamAtStartup(this, on) @@ -1892,6 +1895,7 @@ class FrontEndActivity : ComponentActivity() { steamRepairQueued = SteamRepair.queued(this) mangoapp = SessionPrefs.mangoapp(this) returnAfterGame = SessionPrefs.returnAfterGame(this) + shaderPrecache = SessionPrefs.shaderPrecache(this) steamController = SessionPrefs.steamController(this) runSteamAtStartup = SessionPrefs.runSteamAtStartup(this) loadBootMovie() 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 47ffb2c93..4824a4442 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt @@ -58,6 +58,7 @@ object SessionFiles { "usr/local/bin/droiddeck-steam-install" to "usr/local/bin/droiddeck-steam-install", "usr/local/bin/droiddeck-steam-ui-scale" to "usr/local/bin/droiddeck-steam-ui-scale", "usr/local/bin/droiddeck-steam-language" to "usr/local/bin/droiddeck-steam-language", + "usr/local/bin/droiddeck-steam-shaders" to "usr/local/bin/droiddeck-steam-shaders", "usr/local/bin/droiddeck-steam-desktop-ui" to "usr/local/bin/droiddeck-steam-desktop-ui", "usr/local/bin/droiddeck-steam-library" to "usr/local/bin/droiddeck-steam-library", "usr/local/bin/droiddeck-seed-redists" to "usr/local/bin/droiddeck-seed-redists", diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt index f28c68734..057f87070 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -304,6 +304,24 @@ object SessionPrefs { prefs(context).edit().putBoolean("returnAfterGame", on).apply() } + /** + * Shader pre-caching as the launcher last set it, until the next session writes it into the + * client's config.vdf; after that the client's own toggle is the user's again. + */ + fun pendingShaderPrecache(context: Context): Boolean? = + prefs(context).takeIf { it.contains("shaderPrecache") }?.getBoolean("shaderPrecache", true) + + fun setShaderPrecache(context: Context, on: Boolean) { + prefs(context).edit().putBoolean("shaderPrecache", on).apply() + } + + fun takeShaderPrecache(context: Context): Boolean? = + pendingShaderPrecache(context)?.also { prefs(context).edit().remove("shaderPrecache").apply() } + + fun shaderPrecache(context: Context): Boolean = pendingShaderPrecache(context) + ?: runCatching { com.droiddeck.launcher.storage.SteamStorage.shaderPrecache( + java.io.File(steamRoot(context, steamClientArch(context)), "config/config.vdf").readText()) }.getOrDefault(true) + fun stretch16x9(context: Context): Boolean = prefs(context).getBoolean("stretch16x9", false) fun setStretch16x9(context: Context, on: Boolean) { 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 3db04cabc..253b0fa30 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -673,6 +673,7 @@ class SessionService : Service() { com.droiddeck.launcher.core.AppLanguage.system(this).country, )) guest.add("TZ=" + java.util.TimeZone.getDefault().id) + if (SessionState.mode == MODE_STEAM) SessionPrefs.takeShaderPrecache(this)?.let { guest.add("BL_SHADER_PRECACHE=" + if (it) "1" else "0") } guest.add("XDG_RUNTIME_DIR=" + LinuxRuntime.GUEST_RUNTIME_DIR) guest.add("XDG_SESSION_TYPE=wayland") guest.add("WAYLAND_DISPLAY=wayland-0") diff --git a/app/src/main/java/com/droiddeck/launcher/storage/SteamStorage.kt b/app/src/main/java/com/droiddeck/launcher/storage/SteamStorage.kt index e707fd59f..7fecd5aba 100644 --- a/app/src/main/java/com/droiddeck/launcher/storage/SteamStorage.kt +++ b/app/src/main/java/com/droiddeck/launcher/storage/SteamStorage.kt @@ -28,6 +28,7 @@ object SteamStorage { private val LAST_PLAYED = Regex("^\\s*\"LastPlayed\"\\s*\"(\\d+)\"", RegexOption.MULTILINE) private val MAPPING = Regex("\"(\\d+)\"\\s*\\{\\s*\"name\"\\s*\"([^\"]*)\"") private val COMPAT_NAME = Regex("\"compat_tools\"\\s*\\{\\s*\"([^\"]+)\"") + private val SHADER_CACHE_OFF = Regex("\"DisableShaderCache\"\\s*\"1\"", RegexOption.IGNORE_CASE) data class Manifest( val file: File, val appId: Int, val name: String, val installDir: String?, @@ -118,6 +119,8 @@ object SteamStorage { return MAPPING.findAll(configVdf.substring(open, end)).associate { it.groupValues[1] to it.groupValues[2] } } + fun shaderPrecache(configVdf: String): Boolean = !SHADER_CACHE_OFF.containsMatchIn(configVdf) + fun compatToolName(compatibilityToolVdf: String): String? = COMPAT_NAME.find(compatibilityToolVdf)?.groupValues?.get(1) private val ARM_IN_NAME = Regex("arm64|aarch64") diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt index a444f3711..c546a34c3 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt @@ -82,6 +82,8 @@ class ModeSettings( val steamClientArch: String? = null, /** Steam only: a game launched from the front end returns there when it closes; null hides the row. */ val returnAfterGame: Boolean? = null, + /** Steam only: the client's Shader Pre-Caching setting; null hides the row. */ + val shaderPrecache: Boolean? = null, val steamRepairQueued: Boolean = false, val mangoapp: Boolean = true, val steamController: String? = null, @@ -136,6 +138,7 @@ class ModeSettingsActions( val onSteamClientArch: (String) -> Unit = {}, val onMangoapp: (Boolean) -> Unit = {}, val onReturnAfterGame: (Boolean) -> Unit = {}, + val onShaderPrecache: (Boolean) -> Unit = {}, val onSteamController: (String) -> Unit = {}, val onUnfoldedControls: (Boolean) -> Unit = {}, val onUnfoldedControlsWithController: (Boolean) -> Unit = {}, @@ -478,6 +481,11 @@ fun ModeSheetRows(s: ModeSettings, a: ModeSettingsActions, tab: String, extras: stringResource(R.string.mode_return_after_game_hint), s.returnAfterGame, onChange = a.onReturnAfterGame, ) + if (s.shaderPrecache != null) ToggleRow( + host, "shaderprecache", stringResource(R.string.mode_shader_precache), + stringResource(R.string.mode_shader_precache_hint), + s.shaderPrecache, onChange = a.onShaderPrecache, + ) SettingsRow( stringResource(R.string.mode_steam_repair), stringResource(if (s.steamRepairQueued) R.string.mode_steam_repair_queued else R.string.mode_steam_repair_hint), diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e62952c4f..698634587 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -320,6 +320,8 @@ Turn off if games crash or go black in Deck mode. Applies next session. Return to DroidDeck after a game When a game you started from DroidDeck or another launcher closes, Steam exits cleanly (cloud saves finish first) and you go back there. Off: stay in Steam. + Shader pre-caching + Steam downloads shaders prebuilt for your games and processes them in the background, so games stutter less. Off saves data, storage and battery; games build shaders as they play instead. The same setting as Steam’s Downloads > Shader Pre-Caching. Applies next session. Repair Steam client Downloads the Steam client again the next time Steam starts. Use it if Steam stays on its loading screen. Games, sign-in and settings are kept. The Steam client will be downloaded again the next time Steam starts. diff --git a/app/src/test/java/com/droiddeck/launcher/storage/SteamStorageTest.kt b/app/src/test/java/com/droiddeck/launcher/storage/SteamStorageTest.kt index 794e01d44..6041c7591 100644 --- a/app/src/test/java/com/droiddeck/launcher/storage/SteamStorageTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/storage/SteamStorageTest.kt @@ -92,6 +92,22 @@ class SteamStorageTest { assertEquals(emptyMap(), SteamStorage.compatMappings("\"x\" {}")) } + @Test fun readsShaderPrecacheFromConfig() { + val vdf = """ + "InstallConfigStore" + { + "Software" { "Valve" { "Steam" { "ShaderCacheManager" + { + "HasCurrentBucket" "1" + "DisableShaderCache" "1" + } } } } + } + """.trimIndent() + assertEquals(false, SteamStorage.shaderPrecache(vdf)) + assertEquals(true, SteamStorage.shaderPrecache(vdf.replace("\"DisableShaderCache\"\t\t\"1\"", "\"DisableShaderCache\"\t\t\"0\""))) + assertEquals(true, SteamStorage.shaderPrecache("\"InstallConfigStore\" {}")) + } + @Test fun readsCustomToolName() { val vdf = "\"compatibilitytools\"\n{\n \"compat_tools\"\n {\n \"GE-Proton11-9-aarch64\"\n {\n \"install_path\" \".\"\n }\n }\n}" assertEquals("GE-Proton11-9-aarch64", SteamStorage.compatToolName(vdf)) diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session index c33b78552..809b5d4da 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session @@ -531,6 +531,10 @@ case $mode in fi droiddeck-seed-redists "$steam_root" || true fi + if [ -n "${BL_SHADER_PRECACHE:-}" ]; then + droiddeck-steam-shaders "$BL_SHADER_PRECACHE" "$XDG_DATA_HOME/Steam" \ + "${BL_X64_HOME:-$HOME/.droiddeck-x64}/.local/share/Steam" || true + fi if [ "$steam_x64" = 0 ] && [ ! -f "$steam_root/steamapps/appmanifest_4427310.acf" ]; then step "asking the client to fetch the ARM64 Proton" set -- "$@" steam://install/4427310 diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-shaders b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-shaders new file mode 100755 index 000000000..da93ebec4 --- /dev/null +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-shaders @@ -0,0 +1,61 @@ +#!/usr/bin/python3 +"""Sets the Steam client's shader pre-caching (Settings > Downloads), before the client starts. + + droiddeck-steam-shaders <0|1> ... + +The client stores the toggle as InstallConfigStore/Software/Valve/Steam/ShaderCacheManager/ +DisableShaderCache in config/config.vdf: "1" when off, "0" when on. It keeps the setting in memory +and writes config.vdf back on exit, so this runs only while the client is stopped. The launcher +passes a value only after its own toggle changes; otherwise the client's toggle stays the user's.""" +from pathlib import Path +import runpy +import sys + + +VDF = runpy.run_path(str(Path(__file__).with_name("droiddeck-steam-ui-scale"))) +PATH = ("Software", "Valve", "Steam", "ShaderCacheManager", "DisableShaderCache") + + +def set_text(text, enabled): + value = "0" if enabled else "1" + updated, changed = VDF["seed_value"](text, PATH, value) + if changed: + return updated, True + block = VDF["parse"](text).get("InstallConfigStore") + for section in PATH[:-1]: + block = block.get(section) + current = block.get(PATH[-1]) + if isinstance(current, VDF["Block"]): + raise ValueError("invalid DisableShaderCache setting") + if current == value: + return text, False + at = block.offset(PATH[-1]) + end = VDF["TOKEN"].match(text, at).end() + return text[:at] + f'"{value}"' + text[end:], True + + +def apply(steam_root, enabled): + config = Path(steam_root) / "config/config.vdf" + text = config.read_text() if config.exists() else '"InstallConfigStore"\n{\n}\n' + updated, changed = set_text(text, enabled) + if changed: + VDF["atomic_write"](config, updated) + return changed + + +if __name__ == "__main__": + if len(sys.argv) < 3 or sys.argv[1] not in ("0", "1"): + print("usage: droiddeck-steam-shaders <0|1> ...", file=sys.stderr) + sys.exit(2) + enabled = sys.argv[1] == "1" + status = 0 + for root in sys.argv[2:]: + if not Path(root).is_dir(): + continue + try: + if apply(root, enabled): + print(f"== steam: shader pre-caching {'on' if enabled else 'off'} in {root}") + except (OSError, ValueError, RecursionError) as error: + print(f"== steam: left shader pre-caching unchanged in {root}: {error}", file=sys.stderr) + status = 1 + sys.exit(status) diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-ui-scale b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-ui-scale index 4a5997684..af809d124 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-ui-scale +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-ui-scale @@ -26,9 +26,10 @@ TOKEN = re.compile(r'\s+|//[^\n]*|"(?:\\.|[^"\\])*"|[{}]') class Block: - def __init__(self, pairs, end): + def __init__(self, pairs, end, offsets=()): self.pairs = pairs self.end = end + self.offsets = offsets def get(self, name): matches = [value for key, value in self.pairs if key.lower() == name.lower()] @@ -36,6 +37,9 @@ class Block: raise ValueError("duplicate UI setting") return matches[0] if matches else None + def offset(self, name): + return next(at for (key, _), at in zip(self.pairs, self.offsets) if key.lower() == name.lower()) + def parse(text): tokens = [] @@ -51,17 +55,17 @@ def parse(text): raise ValueError("invalid config.vdf") def block(index, nested=False): - pairs = [] + pairs, offsets = [], [] while index < len(tokens): token, offset = tokens[index] if token == '}': if not nested: raise ValueError("unexpected closing brace") - return Block(pairs, offset), index + 1 + return Block(pairs, offset, offsets), index + 1 if not token.startswith('"') or index + 1 == len(tokens): raise ValueError("missing config value") key = token[1:-1] - value = tokens[index + 1][0] + value, at = tokens[index + 1] index += 2 if value == '{': value, index = block(index, True) @@ -70,9 +74,10 @@ def parse(text): else: raise ValueError("invalid config value") pairs.append((key, value)) + offsets.append(at) if nested: raise ValueError("unclosed config block") - return Block(pairs, len(text)), index + return Block(pairs, len(text), offsets), index return block(0)[0] diff --git a/tools/tests/test_steam_shaders.py b/tools/tests/test_steam_shaders.py new file mode 100644 index 000000000..1d273215a --- /dev/null +++ b/tools/tests/test_steam_shaders.py @@ -0,0 +1,88 @@ +from pathlib import Path +import runpy +import tempfile +import unittest + + +BIN = Path(__file__).resolve().parents[1] / 'linuxfs/overlay/usr/local/bin' +MODULE = runpy.run_path(str(BIN / 'droiddeck-steam-shaders')) +apply = MODULE['apply'] +set_text = MODULE['set_text'] +parse = runpy.run_path(str(BIN / 'droiddeck-steam-ui-scale'))['parse'] + +THOR = '''"InstallConfigStore" +{ +\t"Software" +\t{ +\t\t"Valve" +\t\t{ +\t\t\t"Steam" +\t\t\t{ +\t\t\t\t"ShaderCacheManager" +\t\t\t\t{ +\t\t\t\t\t"HasCurrentBucket"\t\t"1" +\t\t\t\t\t"ProcessingQueue"\t\t"207140;848350;" +\t\t\t\t\t"App" +\t\t\t\t\t{ +\t\t\t\t\t} +\t\t\t\t} +\t\t\t} +\t\t} +\t} +} +''' + + +def disabled(text): + steam = parse(text).get('InstallConfigStore').get('Software').get('Valve').get('Steam') + return steam.get('ShaderCacheManager').get('DisableShaderCache') + + +class SteamShadersTest(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.config = self.root / 'config/config.vdf' + self.config.parent.mkdir() + + def test_off_adds_the_key_the_client_writes(self): + text, changed = set_text(THOR, False) + self.assertTrue(changed) + self.assertEqual('1', disabled(text)) + self.assertIn('\t\t\t\t\t"DisableShaderCache"\t\t"1"\n\t\t\t\t}', text) + self.assertIn('"ProcessingQueue"\t\t"207140;848350;"', text) + + def test_existing_value_is_replaced_in_place(self): + off, _ = set_text(THOR, False) + on, changed = set_text(off, True) + self.assertTrue(changed) + self.assertEqual('0', disabled(on)) + self.assertEqual(off.replace('"DisableShaderCache"\t\t"1"', '"DisableShaderCache"\t\t"0"'), on) + + def test_matching_value_leaves_the_file_alone(self): + off, _ = set_text(THOR, False) + self.assertEqual((off, False), set_text(off, False)) + + def test_missing_sections_are_created(self): + text, changed = set_text('"InstallConfigStore"\n{\n}\n', False) + self.assertTrue(changed) + self.assertEqual('1', disabled(text)) + + def test_apply_writes_config_and_creates_a_fresh_one(self): + self.config.write_text(THOR) + self.assertTrue(apply(self.root, False)) + self.assertEqual('1', disabled(self.config.read_text())) + self.assertFalse(apply(self.root, False)) + fresh = Path(self.temp.name) / 'fresh' + fresh.mkdir() + self.assertTrue(apply(fresh, True)) + self.assertEqual('0', disabled((fresh / 'config/config.vdf').read_text())) + + def test_invalid_config_is_rejected(self): + with self.assertRaises(ValueError): + set_text('"InstallConfigStore" {', False) + + +if __name__ == '__main__': + unittest.main() From b76f13f7acc0af0cc00369dde61fb88e40529e6d Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:33:43 -0400 Subject: [PATCH 24/59] fix: keep the shader pre-caching choice until a session confirms it, and read it from its own VDF section --- .../droiddeck/launcher/FrontEndActivity.kt | 7 ++++- .../launcher/session/SessionPrefs.kt | 26 +++++++++++++------ .../launcher/session/SessionService.kt | 2 +- .../launcher/storage/SteamStorage.kt | 25 ++++++++++++++++-- .../launcher/storage/SteamStorageTest.kt | 8 ++++++ .../overlay/usr/local/bin/droiddeck-session | 3 ++- 6 files changed, 58 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt b/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt index 8ff9a1a48..0180539df 100644 --- a/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt @@ -1722,7 +1722,12 @@ class FrontEndActivity : ComponentActivity() { syncFallback = SessionPrefs.syncFallback(this) }, onSteamRepair = { steamRepairQueued = SteamRepair.queue(this) }, - onSteamClientArch = { id -> SessionPrefs.setSteamClientArch(this, id); steamClientArch = id; refresh() }, + onSteamClientArch = { id -> + SessionPrefs.setSteamClientArch(this, id) + steamClientArch = id + shaderPrecache = SessionPrefs.shaderPrecache(this) + refresh() + }, onMangoapp = { on -> SessionPrefs.setMangoapp(this, on); mangoapp = on }, onReturnAfterGame = { on -> SessionPrefs.setReturnAfterGame(this, on); returnAfterGame = on }, onShaderPrecache = { on -> SessionPrefs.setShaderPrecache(this, on); shaderPrecache = on }, diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt index 057f87070..0b8ef2824 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -304,19 +304,29 @@ object SessionPrefs { prefs(context).edit().putBoolean("returnAfterGame", on).apply() } + private fun shaderPrecacheApplied(context: Context) = + java.io.File(com.droiddeck.launcher.runtime.LinuxRuntime.rootDir(context), "root/.bl-shader-precache-applied") + /** - * Shader pre-caching as the launcher last set it, until the next session writes it into the + * Shader pre-caching as the launcher last set it, until a session reports it written into the * client's config.vdf; after that the client's own toggle is the user's again. */ - fun pendingShaderPrecache(context: Context): Boolean? = - prefs(context).takeIf { it.contains("shaderPrecache") }?.getBoolean("shaderPrecache", true) - - fun setShaderPrecache(context: Context, on: Boolean) { - prefs(context).edit().putBoolean("shaderPrecache", on).apply() + fun pendingShaderPrecache(context: Context): Boolean? { + val prefs = prefs(context) + if (!prefs.contains("shaderPrecache")) return null + val on = prefs.getBoolean("shaderPrecache", true) + val applied = shaderPrecacheApplied(context) + val written = runCatching { applied.readText().trim() }.getOrNull() ?: return on + applied.delete() + if (written != if (on) "1" else "0") return on + prefs.edit().remove("shaderPrecache").apply() + return null } - fun takeShaderPrecache(context: Context): Boolean? = - pendingShaderPrecache(context)?.also { prefs(context).edit().remove("shaderPrecache").apply() } + fun setShaderPrecache(context: Context, on: Boolean) { + shaderPrecacheApplied(context).delete() + prefs(context).edit().putBoolean("shaderPrecache", on).apply() + } fun shaderPrecache(context: Context): Boolean = pendingShaderPrecache(context) ?: runCatching { com.droiddeck.launcher.storage.SteamStorage.shaderPrecache( 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 253b0fa30..f014207e9 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -673,7 +673,7 @@ class SessionService : Service() { com.droiddeck.launcher.core.AppLanguage.system(this).country, )) guest.add("TZ=" + java.util.TimeZone.getDefault().id) - if (SessionState.mode == MODE_STEAM) SessionPrefs.takeShaderPrecache(this)?.let { guest.add("BL_SHADER_PRECACHE=" + if (it) "1" else "0") } + if (SessionState.mode == MODE_STEAM) SessionPrefs.pendingShaderPrecache(this)?.let { guest.add("BL_SHADER_PRECACHE=" + if (it) "1" else "0") } guest.add("XDG_RUNTIME_DIR=" + LinuxRuntime.GUEST_RUNTIME_DIR) guest.add("XDG_SESSION_TYPE=wayland") guest.add("WAYLAND_DISPLAY=wayland-0") diff --git a/app/src/main/java/com/droiddeck/launcher/storage/SteamStorage.kt b/app/src/main/java/com/droiddeck/launcher/storage/SteamStorage.kt index 7fecd5aba..9697addd6 100644 --- a/app/src/main/java/com/droiddeck/launcher/storage/SteamStorage.kt +++ b/app/src/main/java/com/droiddeck/launcher/storage/SteamStorage.kt @@ -28,7 +28,8 @@ object SteamStorage { private val LAST_PLAYED = Regex("^\\s*\"LastPlayed\"\\s*\"(\\d+)\"", RegexOption.MULTILINE) private val MAPPING = Regex("\"(\\d+)\"\\s*\\{\\s*\"name\"\\s*\"([^\"]*)\"") private val COMPAT_NAME = Regex("\"compat_tools\"\\s*\\{\\s*\"([^\"]+)\"") - private val SHADER_CACHE_OFF = Regex("\"DisableShaderCache\"\\s*\"1\"", RegexOption.IGNORE_CASE) + private val VDF_TOKEN = Regex("\"((?:\\\\.|[^\"\\\\])*)\"|[{}]|//[^\n]*") + private val SHADER_CACHE_OFF = listOf("InstallConfigStore", "Software", "Valve", "Steam", "ShaderCacheManager", "DisableShaderCache") data class Manifest( val file: File, val appId: Int, val name: String, val installDir: String?, @@ -119,7 +120,27 @@ object SteamStorage { return MAPPING.findAll(configVdf.substring(open, end)).associate { it.groupValues[1] to it.groupValues[2] } } - fun shaderPrecache(configVdf: String): Boolean = !SHADER_CACHE_OFF.containsMatchIn(configVdf) + fun shaderPrecache(configVdf: String): Boolean = vdfValue(configVdf, SHADER_CACHE_OFF) != "1" + + private fun vdfValue(text: String, path: List): String? { + val blocks = ArrayList() + var key: String? = null + for (token in VDF_TOKEN.findAll(text)) { + val name = key + when { + token.value.startsWith("//") -> {} + token.value == "{" -> { blocks.add(name.orEmpty()); key = null } + token.value == "}" -> { blocks.removeLastOrNull(); key = null } + name == null -> key = token.groupValues[1] + else -> { + val at = blocks + name + if (at.size == path.size && at.zip(path).all { (a, b) -> a.equals(b, ignoreCase = true) }) return token.groupValues[1] + key = null + } + } + } + return null + } fun compatToolName(compatibilityToolVdf: String): String? = COMPAT_NAME.find(compatibilityToolVdf)?.groupValues?.get(1) diff --git a/app/src/test/java/com/droiddeck/launcher/storage/SteamStorageTest.kt b/app/src/test/java/com/droiddeck/launcher/storage/SteamStorageTest.kt index 6041c7591..0cfbf2e05 100644 --- a/app/src/test/java/com/droiddeck/launcher/storage/SteamStorageTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/storage/SteamStorageTest.kt @@ -106,6 +106,14 @@ class SteamStorageTest { assertEquals(false, SteamStorage.shaderPrecache(vdf)) assertEquals(true, SteamStorage.shaderPrecache(vdf.replace("\"DisableShaderCache\"\t\t\"1\"", "\"DisableShaderCache\"\t\t\"0\""))) assertEquals(true, SteamStorage.shaderPrecache("\"InstallConfigStore\" {}")) + assertEquals(true, SteamStorage.shaderPrecache(""" + "InstallConfigStore" + { + // "DisableShaderCache" "1" + "Other" { "DisableShaderCache" "1" } + "Software" { "Valve" { "Steam" { "ShaderCacheManager" { "DisableShaderCache" "0" } } } } + } + """.trimIndent())) } @Test fun readsCustomToolName() { diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session index 809b5d4da..3badd65ec 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session @@ -533,7 +533,8 @@ case $mode in fi if [ -n "${BL_SHADER_PRECACHE:-}" ]; then droiddeck-steam-shaders "$BL_SHADER_PRECACHE" "$XDG_DATA_HOME/Steam" \ - "${BL_X64_HOME:-$HOME/.droiddeck-x64}/.local/share/Steam" || true + "${BL_X64_HOME:-$HOME/.droiddeck-x64}/.local/share/Steam" && + echo "$BL_SHADER_PRECACHE" > "$HOME/.bl-shader-precache-applied" || true fi if [ "$steam_x64" = 0 ] && [ ! -f "$steam_root/steamapps/appmanifest_4427310.acf" ]; then step "asking the client to fetch the ARM64 Proton" From 7c51e6e904ec0caa6015ab9da09ea3fc5fd3fda0 Mon Sep 17 00:00:00 2001 From: 0x1Nawaf Date: Fri, 23 Oct 2026 15:53:11 +0000 Subject: [PATCH 25/59] fix: draw the gamescope cursor only when that option is on The extra plane costs frames on a phone, so compositing stays off unless Performance asks for it. --- .../droiddeck/launcher/FrontEndActivity.kt | 6 ++++- .../launcher/session/SessionPrefs.kt | 7 ++++++ .../launcher/session/SessionService.kt | 1 + .../launcher/session/SettingsReset.kt | 2 +- .../launcher/ui/PerformanceDialog.kt | 7 ++++++ app/src/main/res/values/strings.xml | 2 ++ .../overlay/usr/local/bin/droiddeck-session | 4 +++- tools/tests/test_session_cursor_routing.py | 22 +++++++++++++------ 8 files changed, 41 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt b/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt index 0180539df..1a0639201 100644 --- a/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt @@ -155,6 +155,7 @@ class FrontEndActivity : ComponentActivity() { private var gpuClockPin by mutableStateOf(false) private var prootNoSeccomp by mutableStateOf(false) private var prootFastPath by mutableStateOf(true) + private var cursorComposite by mutableStateOf(false) private var guestHostname by mutableStateOf(SessionPrefs.DEFAULT_GUEST_HOSTNAME) private var phantomWarning by mutableStateOf(null) private var phantomProcessStatus by mutableStateOf(PhantomProcessStatus.NOT_APPLICABLE) @@ -1771,7 +1772,8 @@ class FrontEndActivity : ComponentActivity() { fsyncFirst = fsyncFirst, gamescopeRealtime = gamescopeRealtime, gpuClockPin = gpuClockPin, - prootNoSeccomp = prootNoSeccomp, prootFastPath = prootFastPath, guestHostname = guestHostname, phantomWarning = phantomWarning, + prootNoSeccomp = prootNoSeccomp, prootFastPath = prootFastPath, cursorComposite = cursorComposite, + guestHostname = guestHostname, phantomWarning = phantomWarning, onClientOverride = { on -> SessionPrefs.setClientCpusOverride(this, on); clientOverride = on }, onTuSysmem = { on -> SessionPrefs.setTuSysmem(this, on); tuSysmem = on }, onZinkLazy = { on -> SessionPrefs.setZinkLazy(this, on); zinkLazy = on }, @@ -1785,6 +1787,7 @@ class FrontEndActivity : ComponentActivity() { onGpuClockPin = { on -> SessionPrefs.setGpuClockPin(this, on); gpuClockPin = on }, onProotNoSeccomp = { on -> SessionPrefs.setProotNoSeccomp(this, on); prootNoSeccomp = on }, onProotFastPath = { on -> SessionPrefs.setProotFastPath(this, on); prootFastPath = on }, + onCursorComposite = { on -> SessionPrefs.setCursorComposite(this, on); cursorComposite = on }, onGuestHostname = { name -> SessionPrefs.setGuestHostname(this, name) }, onClientCore = { core, on -> clientCores = if (on) clientCores + core else clientCores - core @@ -1967,6 +1970,7 @@ class FrontEndActivity : ComponentActivity() { gpuClockPin = SessionPrefs.gpuClockPin(this) prootNoSeccomp = SessionPrefs.prootNoSeccomp(this) prootFastPath = SessionPrefs.prootFastPath(this) + cursorComposite = SessionPrefs.cursorComposite(this) guestHostname = SessionPrefs.guestHostname(this) refreshPhantomStatus() } diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt index 0b8ef2824..5d5861d3a 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -472,6 +472,13 @@ object SessionPrefs { prefs(context).edit().putBoolean("prootFastPath", on).apply() } + /** gamescope draws the cursor itself. Off by default: on a phone that plane costs frames. */ + fun cursorComposite(context: Context): Boolean = prefs(context).getBoolean("cursorComposite", false) + + fun setCursorComposite(context: Context, on: Boolean) { + prefs(context).edit().putBoolean("cursorComposite", on).apply() + } + const val DEFAULT_GUEST_HOSTNAME = "DroidDeck" @JvmStatic 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 f014207e9..44f6cc4c4 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -720,6 +720,7 @@ class SessionService : Service() { guest.add("BL_SYNC_FALLBACK=" + (if (SessionPrefs.syncFallback(this)) "1" else "0")) guest.add("BL_FSYNC_FIRST=" + (if (SessionPrefs.fsyncFirst(this)) "1" else "0")) guest.add("BL_GAMESCOPE_REALTIME=" + (if (SessionPrefs.gamescopeRealtime(this)) "1" else "0")) + if (SessionPrefs.cursorComposite(this)) guest.add("BL_CURSOR_COMPOSITE=1") if (com.droiddeck.launcher.core.ZramSupport.supported()) guest.add("BL_ZRAM=1") guest.add("BANNER_AUDIO_DIRECT_DECAY=0") val extra = extraEnv() + com.droiddeck.launcher.agent.AgentEnv.beginSession(this) diff --git a/app/src/main/java/com/droiddeck/launcher/session/SettingsReset.kt b/app/src/main/java/com/droiddeck/launcher/session/SettingsReset.kt index 0c430c1c5..27bc081b0 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SettingsReset.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SettingsReset.kt @@ -20,7 +20,7 @@ object SettingsReset { "gpuDriverMode", "androidDriver", "linuxDriver", "linuxDriver.steam", "linuxDriver.desktop", "tuSysmem", "glThread", "noGlError", "zinkLazy", "mangoapp", "gpuClockPin", "gamescopeRealtime", "directAudio", "mic", "wifiDiscovery", "clientCpusOverride", "clientCpus", "gameCpus", - "noXalia", "fastSync", "fsyncFirst", "syncFallback", "prootNoSeccomp", "prootFastPath", + "noXalia", "fastSync", "fsyncFirst", "syncFallback", "prootNoSeccomp", "prootFastPath", "cursorComposite", "guestHostname", "fexPreset", "forceSsbs", "logs", "storageDiagnostics", "upscaler", "upscaleSharpness", "screenEffects", "textureAnisotropy", "textureLodBias", "steamDownloadsInBackground", "steam_offline", "bootMovie", "bootMovieSound", "bootMovieVolume", "bootMovieSeconds", diff --git a/app/src/main/java/com/droiddeck/launcher/ui/PerformanceDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/PerformanceDialog.kt index b46133463..25f344a77 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/PerformanceDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/PerformanceDialog.kt @@ -46,6 +46,7 @@ fun PerformancePage( gpuClockPin: Boolean, prootNoSeccomp: Boolean, prootFastPath: Boolean, + cursorComposite: Boolean, guestHostname: String, phantomWarning: String?, onClientOverride: (Boolean) -> Unit, @@ -61,6 +62,7 @@ fun PerformancePage( onGpuClockPin: (Boolean) -> Unit, onProotNoSeccomp: (Boolean) -> Unit, onProotFastPath: (Boolean) -> Unit, + onCursorComposite: (Boolean) -> Unit, onGuestHostname: (String) -> Unit, onClientCore: (Int, Boolean) -> Unit, onGameCore: (Int, Boolean) -> Unit, @@ -158,6 +160,11 @@ fun PerformancePage( else stringResource(R.string.perf_fastpath_hint), prootFastPath && !prootNoSeccomp, enabled = !prootNoSeccomp, onChange = onProotFastPath, ) + ToggleRow( + host, "cursorcomposite", stringResource(R.string.perf_cursor_composite), + stringResource(R.string.perf_cursor_composite_hint), + cursorComposite, onChange = onCursorComposite, + ) } SettingsGroup(stringResource(R.string.perf_identity)) { var draft by remember(guestHostname) { mutableStateOf(guestHostname) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 698634587..26fe5a93e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -568,6 +568,8 @@ proot fast path Needs proot\'s seccomp filter. Faster file lookups and less stutter. Turn off if a program can\'t find its files. + Draw the cursor inside gamescope + Gamescope paints the pointer itself instead of handing it to Android. Turn on for a controller-driven cursor. Off is faster on a phone. Applies next session. Session identity Host name The name Steam sees for this device. Blank restores %1$s. diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session index 3badd65ec..d8a44be52 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session @@ -227,7 +227,9 @@ if [ -z "${BL_INSIDE:-}" ]; then echo "== gamescope: games at ${nested}x${height} stretched to the ${width}x${height} output" fi fi - if [ "$mode" = steam ]; then + # Off unless Performance asks for it. Compositing the cursor here adds a second + # plane on the phone's compositor; a controller-driven cursor is the reason to turn it on. + if [ "$mode" = steam ] && [ "${BL_CURSOR_COMPOSITE:-0}" = 1 ]; then export gamescope_cursor_composite=2 fi args=(--backend wayland --expose-wayland -f -W "$width" -H "$height" -w "$nested" -h "$height") diff --git a/tools/tests/test_session_cursor_routing.py b/tools/tests/test_session_cursor_routing.py index fdc88f75d..60d28edc4 100644 --- a/tools/tests/test_session_cursor_routing.py +++ b/tools/tests/test_session_cursor_routing.py @@ -1,4 +1,4 @@ -"""The XTest cursor path is enabled only for Steam sessions.""" +"""Gamescope composites the cursor only when a Steam session asks for it.""" import os from pathlib import Path import re @@ -8,29 +8,37 @@ import unittest SESSION = Path(__file__).resolve().parents[1] / 'linuxfs/overlay/usr/local/bin/droiddeck-session' SOURCE = SESSION.read_text() MATCH = re.search( - r'^ if \[ "\$mode" = steam \]; then\n.*?^ fi\n', SOURCE, re.M | re.S) + r'^ if \[ "\$mode" = steam \] && \[ "\$\{BL_CURSOR_COMPOSITE:-0\}" = 1 \]; then\n.*?^ fi\n', + SOURCE, re.M | re.S) assert MATCH is not None CURSOR_SETUP = MATCH.group(0) class SessionCursorRoutingTest(unittest.TestCase): - def run_setup(self, mode, initial=None): + def run_setup(self, mode, initial=None, composite=None): env = dict(os.environ, mode=mode) if initial is not None: env['gamescope_cursor_composite'] = initial else: env.pop('gamescope_cursor_composite', None) + if composite is not None: + env['BL_CURSOR_COMPOSITE'] = composite + else: + env.pop('BL_CURSOR_COMPOSITE', None) result = subprocess.run( ['bash', '-c', CURSOR_SETUP + 'printf "%s\n" "${gamescope_cursor_composite-}"'], env=env, text=True, capture_output=True, check=True) return result.stdout.strip() - def test_steam_enables_manual_composition_for_the_nested_cursor(self): - self.assertEqual('2', self.run_setup('steam')) + def test_steam_leaves_the_cursor_with_the_host_unless_asked(self): + self.assertEqual('', self.run_setup('steam')) + + def test_steam_composites_the_cursor_when_the_setting_is_on(self): + self.assertEqual('2', self.run_setup('steam', composite='1')) def test_other_sessions_keep_their_existing_cursor_composition(self): - self.assertEqual('', self.run_setup('desktop')) - self.assertEqual('1', self.run_setup('run', '1')) + self.assertEqual('', self.run_setup('desktop', composite='1')) + self.assertEqual('1', self.run_setup('run', '1', composite='1')) if __name__ == '__main__': From 74d5daaa5d759d22f9196b08b8205f2e4eb83467 Mon Sep 17 00:00:00 2001 From: XxJSONDeruloxX Date: Fri, 23 Oct 2026 12:50:08 -0400 Subject: [PATCH 26/59] ci: parallelize regression checks and isolate component caches --- .github/workflows/build.yml | 94 ++++++++++++++++++++------- tools/ci/install-ubuntu-packages.sh | 12 ++++ tools/tests/test_ci_checks.py | 29 +++++++++ tools/tests/test_xbox_build_source.py | 6 +- 4 files changed, 117 insertions(+), 24 deletions(-) create mode 100644 tools/ci/install-ubuntu-packages.sh create mode 100644 tools/tests/test_ci_checks.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ebfd545ca..f2edd43a2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -26,7 +26,7 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' }} env: - PRELOAD_CACHE_VERSION: v6 + PRELOAD_CACHE_VERSION: v7 jobs: xbox: @@ -68,8 +68,7 @@ jobs: if: steps.xbox-cache.outputs.cache-hit != 'true' run: | rustup toolchain install 1.99.0 --profile minimal - sudo apt-get update -qq - sudo apt-get install -y build-essential pkg-config libssl-dev protobuf-compiler cmake xz-utils + bash tools/ci/install-ubuntu-packages.sh build-essential pkg-config libssl-dev protobuf-compiler cmake xz-utils bash tools/xbox/build.sh - uses: actions/upload-artifact@v4 with: @@ -87,7 +86,7 @@ jobs: build: needs: xbox - name: Compile and test APK + name: Compile APK and Android tests if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} runs-on: ubuntu-24.04 timeout-minutes: 45 @@ -254,16 +253,17 @@ jobs: id: preloads uses: actions/cache@v4 with: - path: app/src/main/assets/linuxfs - key: preloads-${{ env.PRELOAD_CACHE_VERSION }}-${{ hashFiles('app/src/main/cpp/fakeinput_steam.cpp', 'app/src/main/cpp/adrenotools/src/hook/kgsl.h', 'tools/quest/*', 'tools/linuxfs/preload/*.c', 'tools/linuxfs/build-x86-preloads.sh', 'tools/linuxfs/fex/*', 'tools/linuxfs/clipboard/*.c', 'tools/linuxfs/ssbs/*.c', 'tools/proot/fastpath/*.c', 'tools/linuxfs/overlay/usr/local/bin/*', 'tools/linuxfs/overlay/usr/bin/**', 'tools/linuxfs/desktop/*', 'tools/gamescope/release.env', 'tools/wlroots/release.env', 'tools/linuxfs/uruntime.env', 'tools/linuxfs/licenses/*', 'tools/mangoapp/*', 'tools/msitools/**', 'tools/comet/**', 'tools/xbox/**', 'tools/linuxfs/debian-image.env', 'tools/vr/**') }} + path: | + app/src/main/assets/linuxfs + !app/src/main/assets/linuxfs/usr/local/lib/droiddeck-comet + !app/src/main/assets/linuxfs/usr/local/lib/droiddeck-xbox + key: preloads-${{ env.PRELOAD_CACHE_VERSION }}-${{ hashFiles('app/src/main/cpp/fakeinput_steam.cpp', 'app/src/main/cpp/adrenotools/src/hook/kgsl.h', 'tools/quest/*', 'tools/linuxfs/preload/*.c', 'tools/linuxfs/build-x86-preloads.sh', 'tools/linuxfs/fex/*', 'tools/linuxfs/clipboard/*.c', 'tools/linuxfs/ssbs/*.c', 'tools/proot/fastpath/*.c', 'tools/linuxfs/overlay/usr/local/bin/*', 'tools/linuxfs/overlay/usr/bin/**', 'tools/linuxfs/desktop/*', 'tools/gamescope/release.env', 'tools/wlroots/release.env', 'tools/linuxfs/uruntime.env', 'tools/linuxfs/licenses/*', 'tools/mangoapp/*', 'tools/msitools/**', 'tools/linuxfs/debian-image.env', 'tools/pipewire/**', 'tools/ci/install-ubuntu-packages.sh', 'tools/vr/**') }} - name: Build libfakeinput.so and libblsession.so (glibc aarch64) if: steps.preloads.outputs.cache-hit != 'true' run: | set -euo pipefail - sudo apt-get -o Dir::Etc::sourcelist=sources.list.d/ubuntu.sources \ - -o Dir::Etc::sourceparts=- -o Acquire::Retries=3 update -qq - sudo apt-get install -y -qq gcc-aarch64-linux-gnu g++-aarch64-linux-gnu libvulkan1 mesa-vulkan-drivers >/dev/null + bash tools/ci/install-ubuntu-packages.sh gcc-aarch64-linux-gnu g++-aarch64-linux-gnu libvulkan1 mesa-vulkan-drivers D=app/src/main/assets/linuxfs mkdir -p "$D" aarch64-linux-gnu-g++ -shared -fPIC -O2 -Wall -Wno-attributes -Wno-nonnull-compare \ @@ -525,9 +525,10 @@ jobs: ~/.cargo/git/db ~/.cargo/bin/cargo-ndk app/src/main/rust/stores/target - key: stores-${{ steps.rust.outputs.rustc }}-${{ hashFiles('app/src/main/rust/stores/Cargo.lock') }} + key: stores-v2-${{ runner.os }}-${{ runner.arch }}-${{ env.NDK_VERSION }}-${{ steps.rust.outputs.rustc }}-${{ hashFiles('app/src/main/rust/stores/Cargo.lock') }}-${{ hashFiles('app/src/main/rust/stores/src/**', 'app/src/main/rust/stores/Cargo.toml', 'app/src/main/rust/stores/build.rs', 'app/src/main/rust/stores/.cargo/**', 'app/build.gradle') }} restore-keys: | - stores-${{ steps.rust.outputs.rustc }}- + stores-v2-${{ runner.os }}-${{ runner.arch }}-${{ env.NDK_VERSION }}-${{ steps.rust.outputs.rustc }}-${{ hashFiles('app/src/main/rust/stores/Cargo.lock') }}- + stores-v2-${{ runner.os }}-${{ runner.arch }}-${{ env.NDK_VERSION }}-${{ steps.rust.outputs.rustc }}- - name: cargo-ndk run: | @@ -550,9 +551,19 @@ jobs: name: xbox-arm64 path: app/src/main/assets/linuxfs/usr/local/lib/droiddeck-xbox + - name: Cache GOG Galaxy companion + id: comet + uses: actions/cache@v4 + with: + path: app/src/main/assets/linuxfs/usr/local/lib/droiddeck-comet + key: comet-${{ runner.os }}-${{ runner.arch }}-rust1.90.0-${{ hashFiles('tools/comet/**') }} + - name: Build GOG Galaxy companion - if: steps.preloads.outputs.cache-hit != 'true' + if: steps.comet.outputs.cache-hit != 'true' run: | + if ! command -v aarch64-linux-gnu-gcc >/dev/null; then + bash tools/ci/install-ubuntu-packages.sh gcc-aarch64-linux-gnu + fi rustup toolchain install 1.90.0 --profile minimal rustup target add --toolchain 1.90.0 aarch64-unknown-linux-gnu RUSTUP_TOOLCHAIN=1.90.0 bash tools/comet/build.sh @@ -598,14 +609,6 @@ jobs: path: app/src/main/rust/stores/Cargo.lock if-no-files-found: ignore - - name: Install Python test dependencies - run: | - sudo apt-get update -qq - sudo apt-get install -y -qq python3-yaml gcc-aarch64-linux-gnu qemu-user - - - name: Steam import and game environment tests - run: python3 -m unittest discover -s tools/tests -p 'test_*.py' - - name: Unit tests id: unit-tests run: | @@ -699,7 +702,7 @@ jobs: echo "The \`droiddeck-apk\` artifact is [on this run]($RUN_URL), signed with the public test key." echo if [ "$SAME_REPO" = true ]; then - echo "A release-signed tester build that installs over Stable or Preview is being published now and will appear in DroidDeck's **Test builds** list. Label the PR \`no-test-build\` to skip that." + echo "After the parallel regression tests pass, a release-signed tester build that installs over Stable or Preview will be published and will appear in DroidDeck's **Test builds** list. Label the PR \`no-test-build\` to skip that." else echo "**Maintainer:** add the \`publish-test\` label to publish a release-signed tester build that installs over Stable or Preview and appears in DroidDeck's **Test builds** list. The label is removed automatically after publishing." fi @@ -761,8 +764,53 @@ jobs: gh api -X POST "repos/${GITHUB_REPOSITORY}/issues/${PR}/comments" -F body=@/tmp/pr-report.md >/dev/null fi + python-tests: + name: Python and native regression tests + needs: xbox + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + fetch-depth: 0 + persist-credentials: false + - name: Merge the pinned APK base + if: github.event_name == 'pull_request' + env: + BUILD_BASE_SHA: ${{ needs.xbox.outputs.base-sha }} + run: | + set -euo pipefail + git fetch --no-tags origin "$BUILD_BASE_SHA" + git config user.name "ci" + git config user.email "ci@users.noreply.github.com" + git merge --no-ff --no-edit FETCH_HEAD + - name: Verify backend and test source match + env: + XBOX_SOURCE_TREE: ${{ needs.xbox.outputs.tree-sha }} + run: test "$(git rev-parse 'HEAD^{tree}')" = "$XBOX_SOURCE_TREE" + - name: Install Python test dependencies + timeout-minutes: 5 + run: bash tools/ci/install-ubuntu-packages.sh python3-yaml gcc-aarch64-linux-gnu qemu-user libvulkan1 mesa-vulkan-drivers + - name: Steam import and game environment tests + run: python3 -m unittest discover -s tools/tests -p 'test_*.py' + + checks: + name: Compile and test APK + needs: [build, python-tests] + if: ${{ always() && (github.event_name != 'pull_request' || !github.event.pull_request.draft) }} + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: Require compilation and every test job + env: + BUILD_RESULT: ${{ needs.build.result }} + PYTHON_RESULT: ${{ needs.python-tests.result }} + run: test "$BUILD_RESULT" = success && test "$PYTHON_RESULT" = success + sign: - needs: build + needs: [build, checks] if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' permissions: contents: read @@ -774,7 +822,7 @@ jobs: secrets: inherit packs: - needs: build + needs: [build, checks] if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' permissions: contents: read diff --git a/tools/ci/install-ubuntu-packages.sh b/tools/ci/install-ubuntu-packages.sh new file mode 100644 index 000000000..d23553178 --- /dev/null +++ b/tools/ci/install-ubuntu-packages.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail +options=( + -o Dir::Etc::sourcelist=sources.list.d/ubuntu.sources + -o Dir::Etc::sourceparts=- + -o Acquire::Retries=3 + -o Acquire::http::Timeout=30 + -o Acquire::https::Timeout=30 + -o DPkg::Lock::Timeout=60 +) +sudo apt-get "${options[@]}" -o APT::Update::Error-Mode=any update -qq +sudo env DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get "${options[@]}" install -y -qq --no-install-recommends "$@" diff --git a/tools/tests/test_ci_checks.py b/tools/tests/test_ci_checks.py new file mode 100644 index 000000000..edeb6dec0 --- /dev/null +++ b/tools/tests/test_ci_checks.py @@ -0,0 +1,29 @@ +import itertools +import os +from pathlib import Path +import subprocess +import unittest + +import yaml + + +ROOT = Path(__file__).resolve().parents[2] + + +class CiChecksTest(unittest.TestCase): + def test_required_status_rejects_failed_cancelled_or_skipped_test_jobs(self): + jobs = yaml.safe_load((ROOT / '.github/workflows/build.yml').read_text())['jobs'] + gate = jobs['checks'] + self.assertEqual(gate['name'], 'Compile and test APK') + self.assertEqual(set(gate['needs']), {'build', 'python-tests'}) + self.assertIn('always()', gate['if']) + for build, python in itertools.product(('success', 'failure', 'cancelled', 'skipped'), repeat=2): + with self.subTest(build=build, python=python): + result = subprocess.run( + ['bash', '-c', gate['steps'][0]['run']], + env=dict(os.environ, BUILD_RESULT=build, PYTHON_RESULT=python), + capture_output=True) + self.assertEqual(result.returncode == 0, build == python == 'success') + for publisher in ('sign', 'packs'): + self.assertIn('checks', jobs[publisher]['needs']) + diff --git a/tools/tests/test_xbox_build_source.py b/tools/tests/test_xbox_build_source.py index 8177a1cbc..15e35ca5e 100644 --- a/tools/tests/test_xbox_build_source.py +++ b/tools/tests/test_xbox_build_source.py @@ -31,7 +31,7 @@ class XboxBuildSourceTest(unittest.TestCase): git('switch', '-q', 'main') (root / 'backend').write_text('merged backend') git('commit', '-qam', 'backend changed after branch') - for name in ('xbox', 'apk'): + for name in ('xbox', 'apk', 'tests'): git('clone', '-q', str(root), str(root / name)) git('checkout', '-q', head, cwd=root / name) output = root / 'output' @@ -46,3 +46,7 @@ class XboxBuildSourceTest(unittest.TestCase): subprocess.run(['bash', '-c', step('build', 'Verify backend and APK source match')], cwd=root / 'apk', env=env, check=True, capture_output=True) self.assertEqual((root / 'apk/backend').read_text(), 'merged backend') self.assertEqual(git('rev-parse', 'HEAD^{tree}', cwd=root / 'apk'), tree) + + subprocess.run(['bash', '-c', step('python-tests', 'Merge the pinned APK base')], cwd=root / 'tests', env=env, check=True, capture_output=True) + subprocess.run(['bash', '-c', step('python-tests', 'Verify backend and test source match')], cwd=root / 'tests', env=env, check=True, capture_output=True) + self.assertEqual(git('rev-parse', 'HEAD^{tree}', cwd=root / 'tests'), tree) From fcf4c09c505d3d856ea1963e9db533f1b460235d Mon Sep 17 00:00:00 2001 From: XxJSONDeruloxX Date: Fri, 23 Oct 2026 12:52:02 -0400 Subject: [PATCH 27/59] test: verify dependency installation fails closed --- tools/tests/test_ci_checks.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tools/tests/test_ci_checks.py b/tools/tests/test_ci_checks.py index edeb6dec0..dd26885fb 100644 --- a/tools/tests/test_ci_checks.py +++ b/tools/tests/test_ci_checks.py @@ -2,6 +2,7 @@ import itertools import os from pathlib import Path import subprocess +import tempfile import unittest import yaml @@ -11,6 +12,28 @@ ROOT = Path(__file__).resolve().parents[2] class CiChecksTest(unittest.TestCase): + def test_dependency_update_errors_block_installation(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + sudo = root / 'sudo' + sudo.write_text('#!/bin/bash\nprintf "%s\\n" "$*" >> "$APT_LOG"\nexit "${APT_RESULT:-0}"\n') + sudo.chmod(0o755) + log = root / 'apt.log' + env = dict(os.environ, PATH=str(root) + os.pathsep + os.environ['PATH'], APT_LOG=str(log)) + installer = ROOT / 'tools/ci/install-ubuntu-packages.sh' + result = subprocess.run(['bash', str(installer), 'qemu-user'], env=dict(env, APT_RESULT='17')) + self.assertEqual(result.returncode, 17) + self.assertEqual(len(log.read_text().splitlines()), 1) + log.unlink() + subprocess.run(['bash', str(installer), 'qemu-user'], env=env, check=True) + update, install = log.read_text().splitlines() + for command in (update, install): + self.assertIn('Dir::Etc::sourceparts=-', command) + self.assertIn('Acquire::https::Timeout=30', command) + self.assertIn('Acquire::Retries=3', command) + self.assertIn('APT::Update::Error-Mode=any', update) + self.assertIn('install -y -qq --no-install-recommends qemu-user', install) + def test_required_status_rejects_failed_cancelled_or_skipped_test_jobs(self): jobs = yaml.safe_load((ROOT / '.github/workflows/build.yml').read_text())['jobs'] gate = jobs['checks'] From 8355c1495b2751e682fc02d5bcce1911dedd00a9 Mon Sep 17 00:00:00 2001 From: XxJSONDeruloxX Date: Fri, 23 Oct 2026 13:00:46 -0400 Subject: [PATCH 28/59] ci: bound apt phases and retry the official Ubuntu mirror --- .github/workflows/build.yml | 2 +- tools/ci/install-ubuntu-packages.sh | 40 ++++++++++++++++++++++++----- tools/tests/test_ci_checks.py | 11 +++++--- 3 files changed, 42 insertions(+), 11 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f2edd43a2..6cbc9a6c0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -791,7 +791,7 @@ jobs: XBOX_SOURCE_TREE: ${{ needs.xbox.outputs.tree-sha }} run: test "$(git rev-parse 'HEAD^{tree}')" = "$XBOX_SOURCE_TREE" - name: Install Python test dependencies - timeout-minutes: 5 + timeout-minutes: 7 run: bash tools/ci/install-ubuntu-packages.sh python3-yaml gcc-aarch64-linux-gnu qemu-user libvulkan1 mesa-vulkan-drivers - name: Steam import and game environment tests run: python3 -m unittest discover -s tools/tests -p 'test_*.py' diff --git a/tools/ci/install-ubuntu-packages.sh b/tools/ci/install-ubuntu-packages.sh index d23553178..1597613aa 100644 --- a/tools/ci/install-ubuntu-packages.sh +++ b/tools/ci/install-ubuntu-packages.sh @@ -1,12 +1,40 @@ #!/usr/bin/env bash set -euo pipefail +missing=() +for package in "$@"; do + if [ "$(dpkg-query -W -f='${Status}' "$package" 2>/dev/null || true)" != 'install ok installed' ]; then + missing+=("$package") + fi +done +if [ "${#missing[@]}" = 0 ]; then + echo "Ubuntu build dependencies are already installed." + exit 0 +fi options=( -o Dir::Etc::sourcelist=sources.list.d/ubuntu.sources -o Dir::Etc::sourceparts=- - -o Acquire::Retries=3 - -o Acquire::http::Timeout=30 - -o Acquire::https::Timeout=30 - -o DPkg::Lock::Timeout=60 + -o Acquire::Retries=1 + -o Acquire::http::Timeout=15 + -o Acquire::https::Timeout=15 + -o DPkg::Lock::Timeout=30 ) -sudo apt-get "${options[@]}" -o APT::Update::Error-Mode=any update -qq -sudo env DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get "${options[@]}" install -y -qq --no-install-recommends "$@" +install_packages() { + echo "Updating Ubuntu package indexes." + sudo timeout --kill-after=10s 60s apt-get "${options[@]}" -o APT::Update::Error-Mode=any update -q || return $? + echo "Installing: ${missing[*]}" + sudo timeout --kill-after=10s 120s env DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get "${options[@]}" install -y -q --no-install-recommends "${missing[@]}" +} +if install_packages; then + exit 0 +fi +echo "::warning::Runner mirror failed or timed out; retrying with the official Ubuntu mirror." +sources=$(mktemp --suffix=.sources) +trap 'rm -f "$sources"' EXIT +mirror=https://archive.ubuntu.com/ubuntu +case "$(dpkg --print-architecture)" in + arm64) mirror=https://ports.ubuntu.com/ubuntu-ports ;; +esac +sed -E "s@^URIs:.*@URIs: $mirror@" /etc/apt/sources.list.d/ubuntu.sources > "$sources" +chmod 644 "$sources" +options[1]="Dir::Etc::sourcelist=$sources" +install_packages diff --git a/tools/tests/test_ci_checks.py b/tools/tests/test_ci_checks.py index dd26885fb..df472a47d 100644 --- a/tools/tests/test_ci_checks.py +++ b/tools/tests/test_ci_checks.py @@ -18,21 +18,24 @@ class CiChecksTest(unittest.TestCase): sudo = root / 'sudo' sudo.write_text('#!/bin/bash\nprintf "%s\\n" "$*" >> "$APT_LOG"\nexit "${APT_RESULT:-0}"\n') sudo.chmod(0o755) + query = root / 'dpkg-query' + query.write_text('#!/bin/sh\nexit 1\n') + query.chmod(0o755) log = root / 'apt.log' env = dict(os.environ, PATH=str(root) + os.pathsep + os.environ['PATH'], APT_LOG=str(log)) installer = ROOT / 'tools/ci/install-ubuntu-packages.sh' result = subprocess.run(['bash', str(installer), 'qemu-user'], env=dict(env, APT_RESULT='17')) self.assertEqual(result.returncode, 17) - self.assertEqual(len(log.read_text().splitlines()), 1) + self.assertEqual(len(log.read_text().splitlines()), 2) log.unlink() subprocess.run(['bash', str(installer), 'qemu-user'], env=env, check=True) update, install = log.read_text().splitlines() for command in (update, install): self.assertIn('Dir::Etc::sourceparts=-', command) - self.assertIn('Acquire::https::Timeout=30', command) - self.assertIn('Acquire::Retries=3', command) + self.assertIn('Acquire::https::Timeout=15', command) + self.assertIn('Acquire::Retries=1', command) self.assertIn('APT::Update::Error-Mode=any', update) - self.assertIn('install -y -qq --no-install-recommends qemu-user', install) + self.assertIn('install -y -q --no-install-recommends qemu-user', install) def test_required_status_rejects_failed_cancelled_or_skipped_test_jobs(self): jobs = yaml.safe_load((ROOT / '.github/workflows/build.yml').read_text())['jobs'] From a4447039b9b377bbd8366bb07826bbfa22f7aa38 Mon Sep 17 00:00:00 2001 From: XxJSONDeruloxX Date: Fri, 23 Oct 2026 13:09:42 -0400 Subject: [PATCH 29/59] ci: install native Vulkan and ARM64 C headers for tests --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6cbc9a6c0..94dbf6baf 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -792,7 +792,7 @@ jobs: run: test "$(git rev-parse 'HEAD^{tree}')" = "$XBOX_SOURCE_TREE" - name: Install Python test dependencies timeout-minutes: 7 - run: bash tools/ci/install-ubuntu-packages.sh python3-yaml gcc-aarch64-linux-gnu qemu-user libvulkan1 mesa-vulkan-drivers + run: bash tools/ci/install-ubuntu-packages.sh python3-yaml gcc-aarch64-linux-gnu libc6-dev-arm64-cross qemu-user libvulkan-dev mesa-vulkan-drivers - name: Steam import and game environment tests run: python3 -m unittest discover -s tools/tests -p 'test_*.py' From fc768a3c4517ba266a0234132751c89685097786 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:55:46 -0400 Subject: [PATCH 30/59] feat: add an A740 UBWC compatibility override --- .../com/droiddeck/launcher/DriverMenus.kt | 14 +++++ .../droiddeck/launcher/FrontEndActivity.kt | 1 + .../com/droiddeck/launcher/SessionActivity.kt | 2 +- .../droiddeck/launcher/core/DeviceReport.kt | 7 ++- .../com/droiddeck/launcher/gpu/TpUbwcHint.kt | 23 ++++++--- .../launcher/session/SessionPrefs.kt | 13 +++++ .../launcher/session/SessionService.kt | 4 +- .../droiddeck/launcher/ui/GpuDriversPanel.kt | 25 +++++++++ .../launcher/wayland/CompositorHost.kt | 3 +- app/src/main/res/values/strings.xml | 3 ++ .../droiddeck/launcher/DriverMenusAutoTest.kt | 14 +++++ .../droiddeck/launcher/gpu/TpUbwcHintTest.kt | 19 +++++++ .../launcher/session/TpUbwcPrefsTest.kt | 51 +++++++++++++++++++ 13 files changed, 168 insertions(+), 11 deletions(-) create mode 100644 app/src/test/java/com/droiddeck/launcher/session/TpUbwcPrefsTest.kt diff --git a/app/src/main/java/com/droiddeck/launcher/DriverMenus.kt b/app/src/main/java/com/droiddeck/launcher/DriverMenus.kt index 7ccbba7f5..a630cc184 100644 --- a/app/src/main/java/com/droiddeck/launcher/DriverMenus.kt +++ b/app/src/main/java/com/droiddeck/launcher/DriverMenus.kt @@ -55,6 +55,9 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl private set private var runtimePending by mutableStateOf(false) private var sessionRunning by mutableStateOf(false) + private var initialTpUbwcMode: String? = null + var tpUbwcMode: String by mutableStateOf(SessionPrefs.TP_UBWC_AUTO) + private set val uiMode: String get() = when (mode) { SessionPrefs.GPU_DRIVERS_AUTO -> GPU_MODE_AUTO @@ -73,8 +76,17 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl mode = uiMode, display = displaySlot(), runtime = runtimeSlot(), canRestart = !sessionRunning, checkedAt = TurnipReleases.cached(activity)?.checkedAt ?: 0L, + tpUbwcMode = tpUbwcMode.takeIf { gpu.model == 740 }, + tpUbwcRestartRequired = (initialTpUbwcMode != null && tpUbwcMode != initialTpUbwcMode) || + com.droiddeck.launcher.gpu.TpUbwcHint.restartRequired(activity), ) + fun chooseTpUbwcMode(mode: String) { + if (gpu.model != 740) return + SessionPrefs.setTpUbwcMode(activity, mode) + tpUbwcMode = mode + } + private fun slotSource(chosenDefault: Boolean, defaultLabel: Int): String = activity.getString(when { uiMode == GPU_MODE_AUTO -> R.string.gpu_src_auto uiMode == GPU_MODE_PAIR && pairRows.any { it.active } -> R.string.gpu_src_pair @@ -124,6 +136,8 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl var androidSelected by mutableStateOf("") fun refreshDrivers() { + tpUbwcMode = SessionPrefs.tpUbwcMode(activity) + if (initialTpUbwcMode == null) initialTpUbwcMode = tpUbwcMode val lm = LinuxVulkanDriverManager(activity) fun origin(id: String) = if (TurnipReleases.isDownloaded(activity, id)) DriverRow.DOWNLOADED else DriverRow.IMPORTED val bundles = DriverBundle.all(activity) diff --git a/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt b/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt index 1a0639201..52aed25cb 100644 --- a/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt @@ -1511,6 +1511,7 @@ class FrontEndActivity : ComponentActivity() { onRestoreBundled = { TurnipDriver(this).restoreBundled(); drivers.refreshDrivers() }, onMode = { m -> drivers.chooseMode(m) }, onRestart = { drivers.restartApp() }, + onTpUbwcMode = { drivers.chooseTpUbwcMode(it) }, ), protons = com.droiddeck.launcher.ui.ProtonsState( rows = protons.protonRows, busyId = protons.protonBusyId, stage = protons.protonStage, percent = protons.protonPercent, diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index b4fd3fd4b..4f159908b 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -1706,7 +1706,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } com.droiddeck.launcher.gpu.DriverSetupLog.write(this, "compositor.start driver=$driverId") CompositorHost.startOrAttach( - holder.surface, runtimeDir.path, + this, holder.surface, runtimeDir.path, driverId?.let { turnip.driverPath(it) }, driverId?.let { turnip.libraryName(it) }, applicationInfo.nativeLibraryDir, size.first, size.second, refreshHz(), outputFpsLimit(), ) diff --git a/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt b/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt index 903851a5a..1e59fb577 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt @@ -200,7 +200,12 @@ object DeviceReport { h("Drivers") val gpu = com.droiddeck.launcher.gpu.GpuInfo.detect() k("GPU", "${gpu.name} · ${gpu.family.label} · ${gpu.supportText}" + if (gpu.oneUi8Gen2) " · One UI 8 Gen 2" else "") - if (gpu.model == 740) k("TP UBWC hint", com.droiddeck.launcher.gpu.TpUbwcHint.forThisDevice() ?: "driver default") + if (gpu.model == 740) { + val hint = com.droiddeck.launcher.gpu.TpUbwcHint + k("TP UBWC hint", hint.forThisDevice(context) ?: "driver default") + k("TP UBWC mode", hint.activeMode ?: SessionPrefs.TP_UBWC_AUTO) + if (hint.restartRequired(context)) k("TP UBWC pending", "${SessionPrefs.tpUbwcMode(context)} (restart required)") + } k("Driver mode", SessionPrefs.gpuDriverMode(context) + (com.droiddeck.launcher.gpu.DriverPairs.recommendedKey(gpu, com.droiddeck.launcher.gpu.DriverPairs.from( com.droiddeck.launcher.gpu.TurnipReleases.cached(context)), com.droiddeck.launcher.gpu.TurnipReleases.autoPicks(context), diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/TpUbwcHint.kt b/app/src/main/java/com/droiddeck/launcher/gpu/TpUbwcHint.kt index 1ccee78e2..40ca3fb7f 100644 --- a/app/src/main/java/com/droiddeck/launcher/gpu/TpUbwcHint.kt +++ b/app/src/main/java/com/droiddeck/launcher/gpu/TpUbwcHint.kt @@ -1,6 +1,8 @@ package com.droiddeck.launcher.gpu +import android.content.Context import android.util.Log +import com.droiddeck.launcher.session.SessionPrefs object TpUbwcHint { const val ENV = "FD_DEV_FEATURES" @@ -8,31 +10,40 @@ object TpUbwcHint { @Volatile private var resolved = false @Volatile private var cached: String? = null + @Volatile var activeMode: String? = null + private set // On Adreno 740, TPL1_DBG_ECO_CNTL1.TP_UBWC_FLAG_HINT is shared by every driver on the GPU and must // match the system driver: blob v6xx leaves it off, newer blobs set it. A mismatch corrupts Android UI. // Samsung's One UI firmware sets it whatever blob version it reports. - internal fun features(model: Int, systemDriverVersion: String?, samsung: Boolean = false): String? { + internal fun features(model: Int, systemDriverVersion: String?, samsung: Boolean = false, + mode: String = SessionPrefs.TP_UBWC_AUTO): String? { if (model != 740) return null + if (mode == SessionPrefs.TP_UBWC_ON) return "enable_tp_ubwc_flag_hint=1" + if (mode == SessionPrefs.TP_UBWC_OFF) return "enable_tp_ubwc_flag_hint=0" if (samsung) return "enable_tp_ubwc_flag_hint=1" val blob = systemDriverVersion?.split('.')?.getOrNull(1)?.toIntOrNull() ?: return null return "enable_tp_ubwc_flag_hint=" + if (blob >= 700) "1" else "0" } @Synchronized - fun forThisDevice(): String? { + fun forThisDevice(context: Context): String? { if (resolved) return cached + val mode = SessionPrefs.tpUbwcMode(context) val gpu = GpuInfo.detect() val model = gpu.model val version = if (model == 740) VulkanInfo.query()["driver_version"] else null - cached = features(model, version, gpu.oneUi8Gen2) + cached = features(model, version, gpu.oneUi8Gen2, mode) + activeMode = mode resolved = true - Log.i(TAG, "Adreno $model${if (gpu.oneUi8Gen2) " (One UI)" else ""}, system driver ${version ?: "n/a"}: ${cached ?: "driver default"}") + Log.i(TAG, "Adreno $model${if (gpu.oneUi8Gen2) " (One UI)" else ""}, system driver ${version ?: "n/a"}, mode $mode: ${cached ?: "driver default"}") return cached } - fun applyToProcess() { - val value = forThisDevice() ?: return + fun restartRequired(context: Context): Boolean = resolved && activeMode != SessionPrefs.tpUbwcMode(context) + + fun applyToProcess(context: Context) { + val value = forThisDevice(context) ?: return runCatching { android.system.Os.setenv(ENV, value, true) } .onFailure { Log.w(TAG, "setenv $ENV failed: ${it.javaClass.simpleName}") } } diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt index 5d5861d3a..7c7a6d419 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -263,6 +263,19 @@ object SessionPrefs { const val GPU_DRIVERS_MANUAL = "manual" const val GPU_DRIVERS_MIX = "mix" + const val TP_UBWC_AUTO = "auto" + const val TP_UBWC_ON = "on" + const val TP_UBWC_OFF = "off" + + fun tpUbwcMode(context: Context): String = + prefs(context).getString("tpUbwcMode", TP_UBWC_AUTO) + ?.takeIf { it in setOf(TP_UBWC_AUTO, TP_UBWC_ON, TP_UBWC_OFF) } ?: TP_UBWC_AUTO + + fun setTpUbwcMode(context: Context, mode: String) { + require(mode in setOf(TP_UBWC_AUTO, TP_UBWC_ON, TP_UBWC_OFF)) + prefs(context).edit().putString("tpUbwcMode", mode).commit() + } + fun gpuDriverMode(context: Context): String = prefs(context).getString("gpuDriverMode", GPU_DRIVERS_AUTO) ?: GPU_DRIVERS_AUTO 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 44f6cc4c4..723b4d36d 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -397,7 +397,7 @@ class SessionService : Service() { private fun runSession(gen: Int) { SessionTerminal.clear() - com.droiddeck.launcher.gpu.TpUbwcHint.forThisDevice() + com.droiddeck.launcher.gpu.TpUbwcHint.forThisDevice(this) try { LinuxRuntime.writeAccounts(this) } catch (e: Exception) { @@ -696,7 +696,7 @@ class SessionService : Service() { val tuOverride = tuDebugOverride() val tuDriver = sysmemDriver(linuxDriverId) (tuOverride ?: "sysmem".takeIf { tuDriver || SessionPrefs.tuSysmem(this) })?.let { guest.add("TU_DEBUG=$it") } - com.droiddeck.launcher.gpu.TpUbwcHint.forThisDevice()?.let { guest.add("${com.droiddeck.launcher.gpu.TpUbwcHint.ENV}=$it") } + com.droiddeck.launcher.gpu.TpUbwcHint.forThisDevice(this)?.let { guest.add("${com.droiddeck.launcher.gpu.TpUbwcHint.ENV}=$it") } if (SessionPrefs.zinkLazy(this)) { guest.add("ZINK_DESCRIPTORS=lazy") guest.add("ZINK_DEBUG=compact") diff --git a/app/src/main/java/com/droiddeck/launcher/ui/GpuDriversPanel.kt b/app/src/main/java/com/droiddeck/launcher/ui/GpuDriversPanel.kt index 47905485d..8474050c3 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/GpuDriversPanel.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/GpuDriversPanel.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.AnnotatedString import com.droiddeck.launcher.gpu.DeviceInfo +import com.droiddeck.launcher.session.SessionPrefs import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import androidx.compose.ui.res.stringResource @@ -128,6 +129,8 @@ class GpuDriversState( val runtime: DriverSlot = DriverSlot(), val canRestart: Boolean = true, val checkedAt: Long = 0L, + val tpUbwcMode: String? = null, + val tpUbwcRestartRequired: Boolean = false, ) { val activePair: PairRow? get() = pairs.firstOrNull { it.active } } @@ -147,6 +150,7 @@ class GpuDriversActions( val onRestoreBundled: () -> Unit = {}, val onMode: (String) -> Unit = {}, val onRestart: () -> Unit = {}, + val onTpUbwcMode: (String) -> Unit = {}, ) @Composable @@ -259,6 +263,27 @@ internal fun GpuDriversBody( GPU_MODE_PAIR -> PairBody(gpu, actions) else -> MixBody(single, actions, slotFocus, onOpenSlot) } + gpu.tpUbwcMode?.let { selected -> + var advanced by rememberSaveable { mutableStateOf(false) } + SecondaryButton(stringResource(R.string.gpu_advanced) + if (advanced) " ▴" else " ▾", modifier = Modifier.fillMaxWidth()) { advanced = !advanced } + AnimatedVisibility(advanced) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + ChoiceRow( + rememberMenuHost(), "tp-ubwc", stringResource(R.string.gpu_ubwc_title), + stringResource(R.string.gpu_ubwc_hint), + listOf( + SessionPrefs.TP_UBWC_AUTO to stringResource(R.string.common_auto), + SessionPrefs.TP_UBWC_ON to stringResource(R.string.widgets_on), + SessionPrefs.TP_UBWC_OFF to stringResource(R.string.widgets_off), + ), selected, onPick = actions.onTpUbwcMode, + ) + if (gpu.tpUbwcRestartRequired) { + Text(stringResource(R.string.gpu_ubwc_restart), fontSize = 13.sp, color = AttentionAmber) + if (gpu.canRestart) SecondaryButton(stringResource(R.string.gpu_restart_now), onClick = actions.onRestart) + } + } + } + } } } diff --git a/app/src/main/java/com/droiddeck/launcher/wayland/CompositorHost.kt b/app/src/main/java/com/droiddeck/launcher/wayland/CompositorHost.kt index bb466c133..348e8ff1f 100644 --- a/app/src/main/java/com/droiddeck/launcher/wayland/CompositorHost.kt +++ b/app/src/main/java/com/droiddeck/launcher/wayland/CompositorHost.kt @@ -23,6 +23,7 @@ object CompositorHost { @Synchronized fun startOrAttach( + context: android.content.Context, surface: Surface, xdgRuntimeDir: String, driverPath: String?, @@ -41,7 +42,7 @@ object CompositorHost { resumeVsync() return false } - com.droiddeck.launcher.gpu.TpUbwcHint.applyToProcess() + com.droiddeck.launcher.gpu.TpUbwcHint.applyToProcess(context) WaylandCompositor.nativeStartWithSurface( surface, xdgRuntimeDir, driverPath, libraryName, nativeLibDir, ) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 26fe5a93e..3f33d6dc2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1103,6 +1103,9 @@ Hide drivers for other GPUs Show drivers for other GPUs Advanced + UBWC compatibility + Try On or Off if Steam or Android shows stripes or flickering. Applies to both drivers after restarting DroidDeck. + Restart DroidDeck to apply. Set together with the display driver: %1$s carries both. Set together with the runtime driver: %1$s carries both. Used by games and the desktop. diff --git a/app/src/test/java/com/droiddeck/launcher/DriverMenusAutoTest.kt b/app/src/test/java/com/droiddeck/launcher/DriverMenusAutoTest.kt index afcb0d4b5..2a1286a2e 100644 --- a/app/src/test/java/com/droiddeck/launcher/DriverMenusAutoTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/DriverMenusAutoTest.kt @@ -53,4 +53,18 @@ class DriverMenusAutoTest { assertEquals("chosen-display", SessionPrefs.androidDriver(activity)) assertEquals("chosen-linux", SessionPrefs.linuxDriver(activity)) } + + @Test fun driverMenusCanBeConstructedBeforeActivityContextIsAttached() { + val menus = DriverMenus(Activity(), Handler(Looper.getMainLooper())) + assertEquals(SessionPrefs.TP_UBWC_AUTO, menus.tpUbwcMode) + } + + @Test fun refreshingDriversLoadsTheSavedUbwcModeAfterContextAttachment() { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + SessionPrefs.setTpUbwcMode(activity, SessionPrefs.TP_UBWC_ON) + val menus = DriverMenus(activity, Handler(Looper.getMainLooper())) + menus.refreshDrivers() + assertEquals(SessionPrefs.TP_UBWC_ON, menus.tpUbwcMode) + assertFalse(menus.state().tpUbwcRestartRequired) + } } diff --git a/app/src/test/java/com/droiddeck/launcher/gpu/TpUbwcHintTest.kt b/app/src/test/java/com/droiddeck/launcher/gpu/TpUbwcHintTest.kt index fe5183cfa..774a5ab9b 100644 --- a/app/src/test/java/com/droiddeck/launcher/gpu/TpUbwcHintTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/gpu/TpUbwcHintTest.kt @@ -3,6 +3,7 @@ package com.droiddeck.launcher.gpu import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Test +import com.droiddeck.launcher.session.SessionPrefs class TpUbwcHintTest { @Test fun a740OnABlobV6xxTurnsTheHintOff() { @@ -18,6 +19,24 @@ class TpUbwcHintTest { assertEquals("enable_tp_ubwc_flag_hint=1", TpUbwcHint.features(740, null, samsung = true)) } + @Test fun onOverridesThePocoRuleEvenWhenTheVersionIsUnreadable() { + for (version in listOf("512.676.1", "512.676.53", "garbage", null)) { + assertEquals("enable_tp_ubwc_flag_hint=1", TpUbwcHint.features(740, version, mode = SessionPrefs.TP_UBWC_ON)) + } + } + + @Test fun offOverridesSamsungAndNewerBlobs() { + assertEquals("enable_tp_ubwc_flag_hint=0", TpUbwcHint.features(740, "512.762.10", mode = SessionPrefs.TP_UBWC_OFF)) + assertEquals("enable_tp_ubwc_flag_hint=0", TpUbwcHint.features(740, null, samsung = true, mode = SessionPrefs.TP_UBWC_OFF)) + } + + @Test fun overridesDoNotApplyToOtherGpus() { + for (mode in listOf(SessionPrefs.TP_UBWC_ON, SessionPrefs.TP_UBWC_OFF)) { + assertNull(TpUbwcHint.features(750, "512.762.10", mode = mode)) + assertNull(TpUbwcHint.features(0, null, mode = mode)) + } + } + @Test fun otherGpusKeepTheirDriverDefault() { assertNull(TpUbwcHint.features(750, "512.762.10")) assertNull(TpUbwcHint.features(830, "512.676.53")) diff --git a/app/src/test/java/com/droiddeck/launcher/session/TpUbwcPrefsTest.kt b/app/src/test/java/com/droiddeck/launcher/session/TpUbwcPrefsTest.kt new file mode 100644 index 000000000..205fc66e8 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/TpUbwcPrefsTest.kt @@ -0,0 +1,51 @@ +package com.droiddeck.launcher.session + +import android.content.Context +import com.droiddeck.launcher.gpu.TpUbwcHint +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class TpUbwcPrefsTest { + private val context: Context get() = RuntimeEnvironment.getApplication() + + @Before fun reset() { + context.getSharedPreferences("session", Context.MODE_PRIVATE).edit().clear().commit() + } + + @Test fun missingOrInvalidChoiceUsesAuto() { + assertEquals(SessionPrefs.TP_UBWC_AUTO, SessionPrefs.tpUbwcMode(context)) + context.getSharedPreferences("session", Context.MODE_PRIVATE).edit().putString("tpUbwcMode", "invalid").commit() + assertEquals(SessionPrefs.TP_UBWC_AUTO, SessionPrefs.tpUbwcMode(context)) + } + + @Test fun overrideIsStoredWithoutChangingDriverChoices() { + SessionPrefs.setAndroidDriver(context, "display") + SessionPrefs.setLinuxDriver(context, "runtime") + for (mode in listOf(SessionPrefs.TP_UBWC_ON, SessionPrefs.TP_UBWC_OFF, SessionPrefs.TP_UBWC_AUTO)) { + SessionPrefs.setTpUbwcMode(context, mode) + assertEquals(mode, SessionPrefs.tpUbwcMode(context)) + assertEquals("display", SessionPrefs.androidDriver(context)) + assertEquals("runtime", SessionPrefs.linuxDriver(context)) + } + } + + @Test fun changingTheSavedChoiceKeepsTheProcessChoiceUntilRestart() { + SessionPrefs.setTpUbwcMode(context, SessionPrefs.TP_UBWC_ON) + val initial = TpUbwcHint.forThisDevice(context) + SessionPrefs.setTpUbwcMode(context, SessionPrefs.TP_UBWC_OFF) + assertTrue(TpUbwcHint.restartRequired(context)) + assertEquals(SessionPrefs.TP_UBWC_ON, TpUbwcHint.activeMode) + assertEquals(initial, TpUbwcHint.forThisDevice(context)) + SessionPrefs.setTpUbwcMode(context, SessionPrefs.TP_UBWC_ON) + assertFalse(TpUbwcHint.restartRequired(context)) + } +} From dccd5a7c694dcef4032a6eebbfc45a9f978e18f2 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:24:25 -0400 Subject: [PATCH 31/59] fix: match the A740 UBWC hint on driver 512.676.76 Co-authored-by: Vabian124 <82999916+Vabian124@users.noreply.github.com> --- .../main/cpp/waylandcomp/src/framegen_bridge.c | 15 ++++++++++++--- app/src/main/cpp/waylandcomp/src/vk_present.c | 4 ++++ .../java/com/droiddeck/launcher/gpu/TpUbwcHint.kt | 9 ++++++--- .../com/droiddeck/launcher/gpu/TpUbwcHintTest.kt | 8 ++++++++ 4 files changed, 30 insertions(+), 6 deletions(-) diff --git a/app/src/main/cpp/waylandcomp/src/framegen_bridge.c b/app/src/main/cpp/waylandcomp/src/framegen_bridge.c index 94bb581a0..9de2824d7 100644 --- a/app/src/main/cpp/waylandcomp/src/framegen_bridge.c +++ b/app/src/main/cpp/waylandcomp/src/framegen_bridge.c @@ -238,7 +238,7 @@ static int ensure_ring(uint32_t w, uint32_t h, VkFormat fmt, uint32_t n) { } g_ring_w = w; g_ring_h = h; g_ring_fmt = fmt; LOGD("framegen: generation ring %ux%u x%u", w, h, n); - return 1; + return 2; } @@ -350,6 +350,7 @@ int vkp_framegen_run(VkCommandBuffer cmd, VkImage scene, VkImageView scene_view, fge_configure((uint32_t)mult, (uint32_t)atomic_load(&g_target), flow, hz, atomic_load(&g_model), atomic_load(&g_preset)); } + const int size_changed = g_built_w != (uint32_t)w || g_built_h != (uint32_t)h || g_built_fmt != fmt; if (!fge_prepare((uint32_t)w, (uint32_t)h, fmt)) { if (fmt != VK_FORMAT_R8G8B8A8_UNORM && fge_unavailable()) { refuse_format(fmt, kind, w, h, "build its chain"); @@ -361,17 +362,25 @@ int vkp_framegen_run(VkCommandBuffer cmd, VkImage scene, VkImageView scene_view, } return 0; } - if (g_built_w != (uint32_t)w || g_built_h != (uint32_t)h || g_built_fmt != fmt) { + if (size_changed) { g_built_w = (uint32_t)w; g_built_h = (uint32_t)h; g_built_fmt = fmt; g_generating_logged = 0; } uint32_t want = (uint32_t)(mult - 1); - if (!ensure_ring((uint32_t)w, (uint32_t)h, fmt, want)) { + const int ring = ensure_ring((uint32_t)w, (uint32_t)h, fmt, want); + if (!ring) { if (fmt != VK_FORMAT_R8G8B8A8_UNORM) { refuse_format(fmt, kind, w, h, "make its generation ring"); return 0; } FGLOG("no memory for the generation ring (%dx%d x%u); frame generation stays off", w, h, want); mark_failed(kind); return 0; } + /* The ring and the engine's images are new on this frame. Dispatching into them now + * null-dereferenced Turnip (vkp_framegen_run, first Steam frame, 2560x1440 to the panel). + * Show this frame as it is; generation starts once the size has settled. */ + if (size_changed || ring == 2) { + FGLOG("frame generation catches up at %dx%d; this frame is shown as it is", w, h); + return 0; + } /* The pacer's source rate must be the game's, not this loop's: a loop slowed by its own * generations would otherwise read as a slower game and justify even more generations. */ diff --git a/app/src/main/cpp/waylandcomp/src/vk_present.c b/app/src/main/cpp/waylandcomp/src/vk_present.c index 96d0b415d..d7f1c9903 100644 --- a/app/src/main/cpp/waylandcomp/src/vk_present.c +++ b/app/src/main/cpp/waylandcomp/src/vk_present.c @@ -1626,6 +1626,10 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i VkImageLayout result_layout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; if (fx) { int mapped_w = (int)(scene_w * g_map.kx + 0.5f), mapped_h = (int)(scene_h * g_map.ky + 0.5f); + /* Frame generation samples every output pixel. Upscaling first made it interpolate + * the whole panel (3200x1440 on a 1440p phone) and rebuild on Steam's first frame. + * Grade at the game's size instead; the screen blit still scales to the panel. */ + if (fg) { mapped_w = scene_w; mapped_h = scene_h; } vkp_effects_set_formats(scene_fmt, scene_fmt); result = vkp_effects_run(cmd, scene_img, scene_w, scene_h, mapped_w, mapped_h, &rw, &rh); result_layout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL; diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/TpUbwcHint.kt b/app/src/main/java/com/droiddeck/launcher/gpu/TpUbwcHint.kt index 40ca3fb7f..893d569a6 100644 --- a/app/src/main/java/com/droiddeck/launcher/gpu/TpUbwcHint.kt +++ b/app/src/main/java/com/droiddeck/launcher/gpu/TpUbwcHint.kt @@ -14,7 +14,7 @@ object TpUbwcHint { private set // On Adreno 740, TPL1_DBG_ECO_CNTL1.TP_UBWC_FLAG_HINT is shared by every driver on the GPU and must - // match the system driver: blob v6xx leaves it off, newer blobs set it. A mismatch corrupts Android UI. + // match the system driver. A mismatch corrupts Android UI. // Samsung's One UI firmware sets it whatever blob version it reports. internal fun features(model: Int, systemDriverVersion: String?, samsung: Boolean = false, mode: String = SessionPrefs.TP_UBWC_AUTO): String? { @@ -22,8 +22,11 @@ object TpUbwcHint { if (mode == SessionPrefs.TP_UBWC_ON) return "enable_tp_ubwc_flag_hint=1" if (mode == SessionPrefs.TP_UBWC_OFF) return "enable_tp_ubwc_flag_hint=0" if (samsung) return "enable_tp_ubwc_flag_hint=1" - val blob = systemDriverVersion?.split('.')?.getOrNull(1)?.toIntOrNull() ?: return null - return "enable_tp_ubwc_flag_hint=" + if (blob >= 700) "1" else "0" + val parts = systemDriverVersion?.split('.') ?: return null + val blob = parts.getOrNull(1)?.toIntOrNull() ?: return null + val patch = parts.getOrNull(2)?.toIntOrNull() ?: 0 + val on = blob >= 700 || (blob == 676 && patch >= 76) + return "enable_tp_ubwc_flag_hint=" + if (on) "1" else "0" } @Synchronized diff --git a/app/src/test/java/com/droiddeck/launcher/gpu/TpUbwcHintTest.kt b/app/src/test/java/com/droiddeck/launcher/gpu/TpUbwcHintTest.kt index 774a5ab9b..28d39e0f5 100644 --- a/app/src/test/java/com/droiddeck/launcher/gpu/TpUbwcHintTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/gpu/TpUbwcHintTest.kt @@ -8,6 +8,14 @@ import com.droiddeck.launcher.session.SessionPrefs class TpUbwcHintTest { @Test fun a740OnABlobV6xxTurnsTheHintOff() { assertEquals("enable_tp_ubwc_flag_hint=0", TpUbwcHint.features(740, "512.676.53")) + assertEquals("enable_tp_ubwc_flag_hint=0", TpUbwcHint.features(740, "512.676.75")) + assertEquals("enable_tp_ubwc_flag_hint=0", TpUbwcHint.features(740, "512.676")) + assertEquals("enable_tp_ubwc_flag_hint=0", TpUbwcHint.features(740, "512.699.10")) + } + + @Test fun a740OnThePocoBlobTurnsTheHintOn() { + assertEquals("enable_tp_ubwc_flag_hint=1", TpUbwcHint.features(740, "512.676.76")) + assertEquals("enable_tp_ubwc_flag_hint=1", TpUbwcHint.features(740, "512.676.80")) } @Test fun a740OnANewerBlobTurnsTheHintOn() { From 2e1156038e8edea66407e86bfeb0d0dc301c7ba2 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:31:55 -0400 Subject: [PATCH 32/59] test: cover the inclusive A740 blob 700 boundary --- app/src/test/java/com/droiddeck/launcher/gpu/TpUbwcHintTest.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/test/java/com/droiddeck/launcher/gpu/TpUbwcHintTest.kt b/app/src/test/java/com/droiddeck/launcher/gpu/TpUbwcHintTest.kt index 28d39e0f5..3335a55aa 100644 --- a/app/src/test/java/com/droiddeck/launcher/gpu/TpUbwcHintTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/gpu/TpUbwcHintTest.kt @@ -19,6 +19,7 @@ class TpUbwcHintTest { } @Test fun a740OnANewerBlobTurnsTheHintOn() { + assertEquals("enable_tp_ubwc_flag_hint=1", TpUbwcHint.features(740, "512.700.0")) assertEquals("enable_tp_ubwc_flag_hint=1", TpUbwcHint.features(740, "512.762.10")) } From e06b942e67b6815e2f9e23363ab6f854bfb85f76 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:06:18 -0400 Subject: [PATCH 33/59] fix: warn about UBWC restart only after a hint is active --- .../com/droiddeck/launcher/DriverMenus.kt | 5 +--- .../droiddeck/launcher/DriverMenusAutoTest.kt | 24 +++++++++++++++++++ .../launcher/session/TpUbwcPrefsTest.kt | 4 ++++ 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/DriverMenus.kt b/app/src/main/java/com/droiddeck/launcher/DriverMenus.kt index a630cc184..344aa7452 100644 --- a/app/src/main/java/com/droiddeck/launcher/DriverMenus.kt +++ b/app/src/main/java/com/droiddeck/launcher/DriverMenus.kt @@ -55,7 +55,6 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl private set private var runtimePending by mutableStateOf(false) private var sessionRunning by mutableStateOf(false) - private var initialTpUbwcMode: String? = null var tpUbwcMode: String by mutableStateOf(SessionPrefs.TP_UBWC_AUTO) private set @@ -77,8 +76,7 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl canRestart = !sessionRunning, checkedAt = TurnipReleases.cached(activity)?.checkedAt ?: 0L, tpUbwcMode = tpUbwcMode.takeIf { gpu.model == 740 }, - tpUbwcRestartRequired = (initialTpUbwcMode != null && tpUbwcMode != initialTpUbwcMode) || - com.droiddeck.launcher.gpu.TpUbwcHint.restartRequired(activity), + tpUbwcRestartRequired = com.droiddeck.launcher.gpu.TpUbwcHint.restartRequired(activity), ) fun chooseTpUbwcMode(mode: String) { @@ -137,7 +135,6 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl fun refreshDrivers() { tpUbwcMode = SessionPrefs.tpUbwcMode(activity) - if (initialTpUbwcMode == null) initialTpUbwcMode = tpUbwcMode val lm = LinuxVulkanDriverManager(activity) fun origin(id: String) = if (TurnipReleases.isDownloaded(activity, id)) DriverRow.DOWNLOADED else DriverRow.IMPORTED val bundles = DriverBundle.all(activity) diff --git a/app/src/test/java/com/droiddeck/launcher/DriverMenusAutoTest.kt b/app/src/test/java/com/droiddeck/launcher/DriverMenusAutoTest.kt index 2a1286a2e..4be7a14e6 100644 --- a/app/src/test/java/com/droiddeck/launcher/DriverMenusAutoTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/DriverMenusAutoTest.kt @@ -6,9 +6,11 @@ import android.os.Handler import android.os.Looper import com.droiddeck.launcher.session.QuestExperimental import com.droiddeck.launcher.session.SessionPrefs +import com.droiddeck.launcher.gpu.TpUbwcHint import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -17,11 +19,15 @@ import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment import org.robolectric.annotation.Config import org.robolectric.shadows.ShadowBuild +import org.robolectric.util.ReflectionHelpers @RunWith(RobolectricTestRunner::class) @Config(sdk = [28], manifest = Config.NONE) class DriverMenusAutoTest { @Before fun reset() { + ReflectionHelpers.setStaticField(TpUbwcHint::class.java, "resolved", false) + ReflectionHelpers.setStaticField(TpUbwcHint::class.java, "cached", null) + ReflectionHelpers.setStaticField(TpUbwcHint::class.java, "activeMode", null) RuntimeEnvironment.getApplication().getSharedPreferences("session", Context.MODE_PRIVATE) .edit().clear().commit() ShadowBuild.setModel("Quest 3") @@ -67,4 +73,22 @@ class DriverMenusAutoTest { assertEquals(SessionPrefs.TP_UBWC_ON, menus.tpUbwcMode) assertFalse(menus.state().tpUbwcRestartRequired) } + + @Test fun changingUbwcBeforeTheFirstSessionDoesNotRequireRestart() { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val menus = DriverMenus(activity, Handler(Looper.getMainLooper())) + menus.refreshDrivers() + SessionPrefs.setTpUbwcMode(activity, SessionPrefs.TP_UBWC_ON) + menus.refreshDrivers() + assertFalse(menus.state().tpUbwcRestartRequired) + } + + @Test fun recreatingTheMenuKeepsTheActiveHintRestartWarning() { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + TpUbwcHint.forThisDevice(activity) + SessionPrefs.setTpUbwcMode(activity, SessionPrefs.TP_UBWC_ON) + val menus = DriverMenus(activity, Handler(Looper.getMainLooper())) + menus.refreshDrivers() + assertTrue(menus.state().tpUbwcRestartRequired) + } } diff --git a/app/src/test/java/com/droiddeck/launcher/session/TpUbwcPrefsTest.kt b/app/src/test/java/com/droiddeck/launcher/session/TpUbwcPrefsTest.kt index 205fc66e8..2315fada3 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/TpUbwcPrefsTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/TpUbwcPrefsTest.kt @@ -11,6 +11,7 @@ import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment import org.robolectric.annotation.Config +import org.robolectric.util.ReflectionHelpers @RunWith(RobolectricTestRunner::class) @Config(sdk = [28], manifest = Config.NONE) @@ -18,6 +19,9 @@ class TpUbwcPrefsTest { private val context: Context get() = RuntimeEnvironment.getApplication() @Before fun reset() { + ReflectionHelpers.setStaticField(TpUbwcHint::class.java, "resolved", false) + ReflectionHelpers.setStaticField(TpUbwcHint::class.java, "cached", null) + ReflectionHelpers.setStaticField(TpUbwcHint::class.java, "activeMode", null) context.getSharedPreferences("session", Context.MODE_PRIVATE).edit().clear().commit() } From 16bedf36357e42f78c8e696374aeda76a1d7aa4a Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:23:26 -0400 Subject: [PATCH 34/59] fix: keep the UBWC hint consistent after custom guest environment --- .../main/java/com/droiddeck/launcher/session/SessionService.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 723b4d36d..86643fc15 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -696,7 +696,6 @@ class SessionService : Service() { val tuOverride = tuDebugOverride() val tuDriver = sysmemDriver(linuxDriverId) (tuOverride ?: "sysmem".takeIf { tuDriver || SessionPrefs.tuSysmem(this) })?.let { guest.add("TU_DEBUG=$it") } - com.droiddeck.launcher.gpu.TpUbwcHint.forThisDevice(this)?.let { guest.add("${com.droiddeck.launcher.gpu.TpUbwcHint.ENV}=$it") } if (SessionPrefs.zinkLazy(this)) { guest.add("ZINK_DESCRIPTORS=lazy") guest.add("ZINK_DEBUG=compact") @@ -726,6 +725,7 @@ class SessionService : Service() { val extra = extraEnv() + com.droiddeck.launcher.agent.AgentEnv.beginSession(this) if (extra.none { it.startsWith("TU_DEBUG=") }) guest.add("$GAME_TU_DEBUG=" + (tuOverride ?: if (tuDriver) "sysmem" else "")) extra.forEach { guest.add(it) } + com.droiddeck.launcher.gpu.TpUbwcHint.forThisDevice(this)?.let { guest.add("${com.droiddeck.launcher.gpu.TpUbwcHint.ENV}=$it") } val clientPinned = steamHere && SessionPrefs.clientCpusOverride(this) if (clientPinned) { guest.add("BL_CLIENT_CPUS=" + CpuCores.listOrAll(SessionPrefs.clientCpus(this))) From 293e84a64c038ca014a66a20d45966b78b1cd996 Mon Sep 17 00:00:00 2001 From: maxjivi05 Date: Fri, 23 Oct 2026 13:57:38 -0400 Subject: [PATCH 35/59] feat: droiddeck-ntsync is the default Wine sync - New installs start on ntsync. - Existing installs move to ntsync once on the first start of this version, whatever sync they used before. A choice made after that is kept. - The ntsync switch is no longer labelled experimental or for testing, in every language. --- .../main/java/com/droiddeck/launcher/App.kt | 1 + .../launcher/session/SessionPrefs.kt | 13 ++++++++- app/src/main/res/values-ar/strings.xml | 4 +-- app/src/main/res/values-b+es+419/strings.xml | 4 +-- app/src/main/res/values-bg/strings.xml | 4 +-- app/src/main/res/values-cs/strings.xml | 4 +-- app/src/main/res/values-da/strings.xml | 4 +-- app/src/main/res/values-de/strings.xml | 4 +-- app/src/main/res/values-el/strings.xml | 4 +-- app/src/main/res/values-es/strings.xml | 4 +-- app/src/main/res/values-fi/strings.xml | 4 +-- app/src/main/res/values-fr/strings.xml | 4 +-- app/src/main/res/values-hi/strings.xml | 4 +-- app/src/main/res/values-hu/strings.xml | 4 +-- app/src/main/res/values-in/strings.xml | 4 +-- app/src/main/res/values-it/strings.xml | 4 +-- app/src/main/res/values-ja/strings.xml | 4 +-- app/src/main/res/values-ko/strings.xml | 4 +-- app/src/main/res/values-ms/strings.xml | 4 +-- app/src/main/res/values-nb/strings.xml | 4 +-- app/src/main/res/values-nl/strings.xml | 4 +-- app/src/main/res/values-pl/strings.xml | 4 +-- app/src/main/res/values-pt-rPT/strings.xml | 4 +-- app/src/main/res/values-pt/strings.xml | 4 +-- app/src/main/res/values-ro/strings.xml | 4 +-- app/src/main/res/values-ru/strings.xml | 4 +-- app/src/main/res/values-sv/strings.xml | 4 +-- app/src/main/res/values-th/strings.xml | 4 +-- app/src/main/res/values-tr/strings.xml | 4 +-- app/src/main/res/values-uk/strings.xml | 4 +-- app/src/main/res/values-vi/strings.xml | 4 +-- app/src/main/res/values-zh-rCN/strings.xml | 4 +-- app/src/main/res/values-zh-rHK/strings.xml | 4 +-- app/src/main/res/values-zh-rTW/strings.xml | 4 +-- app/src/main/res/values/strings.xml | 4 +-- .../launcher/session/SyncBackendTest.kt | 29 +++++++++++++++++-- 36 files changed, 106 insertions(+), 69 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/App.kt b/app/src/main/java/com/droiddeck/launcher/App.kt index 3fc438377..aa26d5fca 100644 --- a/app/src/main/java/com/droiddeck/launcher/App.kt +++ b/app/src/main/java/com/droiddeck/launcher/App.kt @@ -28,6 +28,7 @@ class App : Application() { if (process == "$packageName:vr_recovery" || process == "$packageName:rgb_diagnostic") return CrashHandler.install(this) com.droiddeck.launcher.session.QuestExperimental.initialize(this) + com.droiddeck.launcher.session.SessionPrefs.applyNtsyncDefault(this) com.droiddeck.launcher.session.SessionPrefs.settleGpuDriverMode(this) com.droiddeck.launcher.update.AppUpdates.init(this) com.droiddeck.launcher.core.DownloadSource.init(this) diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt index d9d2d8e1d..4fbdd1180 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -376,7 +376,7 @@ object SessionPrefs { prefs(context).edit().putBoolean("noXalia", on).apply() } - fun fastSync(context: Context): Boolean = prefs(context).getBoolean("fastSync", false) + fun fastSync(context: Context): Boolean = prefs(context).getBoolean("fastSync", true) fun setFastSync(context: Context, on: Boolean) { prefs(context).edit().putBoolean("fastSync", on).apply() @@ -411,6 +411,17 @@ object SessionPrefs { .apply() } + fun applyNtsyncDefault(context: Context) { + val prefs = prefs(context) + if (prefs.getBoolean("ntsyncDefaultV1", false)) return + prefs.edit() + .putBoolean("fastSync", true) + .putBoolean("fsyncFirst", false) + .putBoolean("syncFallback", true) + .putBoolean("ntsyncDefaultV1", true) + .apply() + } + fun syncFallback(context: Context): Boolean = prefs(context).getBoolean("syncFallback", true) fun setSyncFallback(context: Context, on: Boolean) { diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 7001fa916..5b0acda79 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -549,8 +549,8 @@ Turnip: التصيير عبر sysmem أسرع لعميل Steam؛ في الألعاب يختار برنامج التشغيل بين العرض المقسّم إلى مربعات والعرض في ذاكرة النظام لكل مرحلة. مطلوب على Adreno 710/720/722. تجاوز مساعد xalia في Steam - droiddeck-ntsync (تجريبي) - بديل في مساحة المستخدم لبرنامج تشغيل ntsync في Linux، للاختبار. أثناء تفعيله، يستخدمه Proton بدلًا من droiddeck-esync وdroiddeck-fsync. يعمل مع Proton Experimental ARM64 وGE-Proton وproton-cachyos. يُطبَّق في الجلسة التالية. + droiddeck-ntsync + بديل في مساحة المستخدم لبرنامج تشغيل ntsync في Linux. أثناء تفعيله، يستخدمه Proton بدلًا من droiddeck-esync وdroiddeck-fsync. يعمل مع Proton Experimental ARM64 وGE-Proton وproton-cachyos. يُطبَّق في الجلسة التالية. droiddeck-esync droiddeck-fsync أولًا يشغّل خاصية fsync المدمجة في Proton على ملفات Proton الأصلية، مع توفير futex_waitv في مساحة المستخدم، فلا يحتاج إلى حزمة ويستمر في العمل لحظة تحديث Steam لـ Proton. أثناء تفعيله، تستخدمه الألعاب قبل droiddeck-esync، باستثناء القليل الذي يبقيه Proton معطّلًا لـ fsync. يُطبَّق في الجلسة التالية. diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index 16db21a9b..4d1c38fb2 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -536,8 +536,8 @@ Turnip: renderizado sysmem Más rápido para el cliente de Steam; en los juegos, el controlador elige entre renderizado por mosaicos o en memoria del sistema en cada pasada. Obligatorio en Adreno 710/720/722. Omitir el asistente xalia de Steam - droiddeck-ntsync (experimental) - Un sustituto en espacio de usuario del controlador ntsync de Linux, para pruebas. Mientras está activado, Proton lo usa en lugar de droiddeck-esync y droiddeck-fsync. Funciona con Proton Experimental ARM64, GE-Proton y proton-cachyos. Se aplica en la próxima sesión. + droiddeck-ntsync + Un sustituto en espacio de usuario del controlador ntsync de Linux. Mientras está activado, Proton lo usa en lugar de droiddeck-esync y droiddeck-fsync. Funciona con Proton Experimental ARM64, GE-Proton y proton-cachyos. Se aplica en la próxima sesión. droiddeck-esync droiddeck-fsync primero Usa el fsync propio de Proton con los archivos originales de Proton, con futex_waitv proporcionado en espacio de usuario, así que no necesita paquete y sigue funcionando en cuanto Steam actualiza Proton. Mientras está activado, los juegos lo usan antes que droiddeck-esync, salvo los pocos que Proton mantiene sin fsync. Se aplica en la próxima sesión. diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index 9dcc424a1..358318565 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -541,8 +541,8 @@ Turnip: рендиране sysmem По-бързо за клиента на Steam; в игрите драйверът избира за всеки проход между рендериране на плочки и в системната памет. Задължително на Adreno 710/720/722. Пропускане на помощника xalia на Steam - droiddeck-ntsync (експериментално) - Заместител на потребителско ниво за драйвера ntsync на Linux, за тестване. Докато е включен, Proton го използва вместо droiddeck-esync и droiddeck-fsync. Работи с Proton Experimental ARM64, GE-Proton и proton-cachyos. Прилага се от следващата сесия. + droiddeck-ntsync + Заместител на потребителско ниво за драйвера ntsync на Linux. Докато е включен, Proton го използва вместо droiddeck-esync и droiddeck-fsync. Работи с Proton Experimental ARM64, GE-Proton и proton-cachyos. Прилага се от следващата сесия. droiddeck-esync droiddeck-fsync първо Стартира собствения fsync на Proton върху оригиналните файлове на Proton, с futex_waitv, подаден от потребителско ниво, затова не изисква пакет и продължава да работи веднага след актуализация на Proton от Steam. Докато е включен, игрите го използват преди droiddeck-esync, освен малкото, които Proton държи извън fsync. Прилага се от следващата сесия. diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 97e679960..938597a14 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -545,8 +545,8 @@ Turnip: vykreslování sysmem Rychlejší pro klienta Steam; ve hrách ovladač pro každý průchod volí dlaždicové vykreslování, nebo vykreslování v systémové paměti. Vyžadováno na Adreno 710/720/722. Vynechat Steamova pomocníka xalia - droiddeck-ntsync (experimentální) - Uživatelská náhrada linuxového ovladače ntsync, pro testování. Když je zapnuto, Proton ho používá místo droiddeck-esync a droiddeck-fsync. Funguje s Proton Experimental ARM64, GE-Proton a proton-cachyos. Použije se od příští relace. + droiddeck-ntsync + Uživatelská náhrada linuxového ovladače ntsync. Když je zapnuto, Proton ho používá místo droiddeck-esync a droiddeck-fsync. Funguje s Proton Experimental ARM64, GE-Proton a proton-cachyos. Použije se od příští relace. droiddeck-esync droiddeck-fsync jako první Spouští vlastní fsync Protonu na původních souborech Protonu, s futex_waitv dodaným v uživatelském prostoru, takže nepotřebuje balíček a funguje i hned po aktualizaci Protonu Steamem. Když je zapnuto, hry ho používají před droiddeck-esync, kromě těch pár, které Proton u fsync nechává vypnuté. Použije se od příští relace. diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 0c2bc230b..4d0230790 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -541,8 +541,8 @@ Turnip: sysmem-rendering Hurtigere til Steam-klienten; i spil vælger driveren flise- eller systemhukommelses-rendering for hvert pass. Påkrævet på Adreno 710/720/722. Spring Steams xalia-hjælper over - droiddeck-ntsync (eksperimentel) - En stand-in til ntsync-driveren i Linux-kernen, i brugerrum, til test. Mens den er til, bruger Proton den i stedet for droiddeck-esync og droiddeck-fsync. Fungerer med Proton Experimental ARM64, GE-Proton og proton-cachyos. Gælder fra næste session. + droiddeck-ntsync + En stand-in til ntsync-driveren i Linux-kernen, i brugerrum. Mens den er til, bruger Proton den i stedet for droiddeck-esync og droiddeck-fsync. Fungerer med Proton Experimental ARM64, GE-Proton og proton-cachyos. Gælder fra næste session. droiddeck-esync droiddeck-fsync først Kører Protons egen fsync på de uændrede Proton-filer, med futex_waitv leveret i brugerrum, så det kræver ingen pack og fortsætter med at virke, lige så snart Steam opdaterer Proton. Mens den er til, bruger spil den før droiddeck-esync, undtagen de få, Proton holder uden for fsync. Gælder fra næste session. diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index c09e49dd7..a25032d28 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -541,8 +541,8 @@ Turnip: Sysmem-Rendering Schneller für den Steam-Client; in Spielen wählt der Treiber pro Durchgang zwischen Kachel- und Systemspeicher-Rendering. Erforderlich bei Adreno 710/720/722. Steams xalia-Helfer überspringen - droiddeck-ntsync (experimentell) - Ein Userspace-Ersatz für den Linux-Treiber ntsync, zum Testen. Solange er aktiv ist, nutzt Proton ihn statt droiddeck-esync und droiddeck-fsync. Funktioniert mit Proton Experimental ARM64, GE-Proton und proton-cachyos. Gilt ab der nächsten Sitzung. + droiddeck-ntsync + Ein Userspace-Ersatz für den Linux-Treiber ntsync. Solange er aktiv ist, nutzt Proton ihn statt droiddeck-esync und droiddeck-fsync. Funktioniert mit Proton Experimental ARM64, GE-Proton und proton-cachyos. Gilt ab der nächsten Sitzung. droiddeck-esync droiddeck-fsync bevorzugen Führt Protons eigenes fsync mit den unveränderten Proton-Dateien aus, wobei futex_waitv im Userspace bereitgestellt wird. Es braucht daher kein Pack und funktioniert weiter, sobald Steam Proton aktualisiert. Solange es aktiv ist, nutzen Spiele es vor droiddeck-esync, außer den wenigen, die Proton von fsync fernhält. Gilt ab der nächsten Sitzung. diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 181822f35..03ab295a9 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -541,8 +541,8 @@ Turnip: απόδοση sysmem Ταχύτερο για το πρόγραμμα-πελάτη Steam· στα παιχνίδια ο οδηγός επιλέγει σε κάθε πέρασμα απόδοση με πλακίδια ή στη μνήμη συστήματος. Απαιτείται σε Adreno 710/720/722. Παράβλεψη του βοηθού xalia του Steam - droiddeck-ntsync (πειραματικό) - Ένα υποκατάστατο σε επίπεδο χρήστη για το πρόγραμμα οδήγησης ntsync του Linux, για δοκιμές. Όσο είναι ενεργό, το Proton το χρησιμοποιεί αντί για το droiddeck-esync και το droiddeck-fsync. Λειτουργεί με Proton Experimental ARM64, GE-Proton και proton-cachyos. Εφαρμόζεται στην επόμενη συνεδρία. + droiddeck-ntsync + Ένα υποκατάστατο σε επίπεδο χρήστη για το πρόγραμμα οδήγησης ntsync του Linux. Όσο είναι ενεργό, το Proton το χρησιμοποιεί αντί για το droiddeck-esync και το droiddeck-fsync. Λειτουργεί με Proton Experimental ARM64, GE-Proton και proton-cachyos. Εφαρμόζεται στην επόμενη συνεδρία. droiddeck-esync droiddeck-fsync πρώτα Εκτελεί το δικό του fsync του Proton στα απλά αρχεία Proton, με το futex_waitv παρεχόμενο σε επίπεδο χρήστη, οπότε δεν χρειάζεται πακέτο και συνεχίζει να λειτουργεί τη στιγμή που το Steam ενημερώνει το Proton. Όσο είναι ενεργό, τα παιχνίδια το χρησιμοποιούν πριν από το droiddeck-esync, εκτός από τα λίγα που το Proton κρατά εκτός fsync. Εφαρμόζεται στην επόμενη συνεδρία. diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index ec99ab719..c8f94321e 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -545,8 +545,8 @@ Turnip: renderizado sysmem Más rápido para el cliente de Steam; en los juegos, el controlador elige entre renderizado por mosaicos o en memoria del sistema en cada pasada. Obligatorio en Adreno 710/720/722. Omitir el asistente xalia de Steam - droiddeck-ntsync (experimental) - Un sustituto en espacio de usuario del controlador ntsync de Linux, para pruebas. Mientras está activado, Proton lo usa en lugar de droiddeck-esync y droiddeck-fsync. Funciona con Proton Experimental ARM64, GE-Proton y proton-cachyos. Se aplica en la próxima sesión. + droiddeck-ntsync + Un sustituto en espacio de usuario del controlador ntsync de Linux. Mientras está activado, Proton lo usa en lugar de droiddeck-esync y droiddeck-fsync. Funciona con Proton Experimental ARM64, GE-Proton y proton-cachyos. Se aplica en la próxima sesión. droiddeck-esync droiddeck-fsync primero Usa el fsync propio de Proton con los archivos originales de Proton, con futex_waitv proporcionado en espacio de usuario, así que no necesita paquete y sigue funcionando en cuanto Steam actualiza Proton. Mientras está activado, los juegos lo usan antes que droiddeck-esync, salvo los pocos que Proton mantiene sin fsync. Se aplica en la próxima sesión. diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index ac12cc7e8..31695f9a1 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -541,8 +541,8 @@ Turnip: sysmem-renderöinti Nopeampi Steam-asiakasohjelmalle; peleissä ajuri valitsee jokaiselle vaiheelle ruuduittaisen tai järjestelmämuistiin tehtävän renderöinnin. Pakollinen Adreno 710/720/722:lla. Ohita Steamin xalia-avustaja - droiddeck-ntsync (kokeellinen) - Käyttäjätilainen korvike Linuxin ntsync-ajurille, testausta varten. Kun se on päällä, Proton käyttää sitä droiddeck-esyncin ja droiddeck-fsyncin sijaan. Toimii Proton Experimental ARM64:n, GE-Protonin ja proton-cachyosin kanssa. Tulee voimaan seuraavassa istunnossa. + droiddeck-ntsync + Käyttäjätilainen korvike Linuxin ntsync-ajurille. Kun se on päällä, Proton käyttää sitä droiddeck-esyncin ja droiddeck-fsyncin sijaan. Toimii Proton Experimental ARM64:n, GE-Protonin ja proton-cachyosin kanssa. Tulee voimaan seuraavassa istunnossa. droiddeck-esync droiddeck-fsync etusijalle Käynnistää Protonin omaa fsynciä vakiotiedostoilla, futex_waitv tarjottuna käyttäjätilassa, niin se ei tarvitse pakettia ja toimii edelleen heti kun Steam päivittää Protonin. Kun se on päällä, pelit käyttävät sitä droiddeck-esyncin edellä, paitsi harvat, jotka Proton pitää pois fsyncistä. Tulee voimaan seuraavassa istunnossa. diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 143d11449..8410619a6 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -545,8 +545,8 @@ Turnip : rendu sysmem Le client Steam sera plus rapide ; en jeu, le pilote choisit à chaque passe entre rendu par tuiles et rendu en mémoire système. Requis pour Adreno 710/720/722. Ignorer l\'assistant xalia de Steam - droiddeck-ntsync (expérimental) - Un substitut en espace utilisateur du pilote Linux ntsync, pour les tests. Lorsqu\'il est activé, Proton l\'utilise à la place de droiddeck-esync et de droiddeck-fsync. Fonctionne avec Proton Experimental ARM64, GE-Proton et proton-cachyos. Prendra effet à la prochaine session. + droiddeck-ntsync + Un substitut en espace utilisateur du pilote Linux ntsync. Lorsqu\'il est activé, Proton l\'utilise à la place de droiddeck-esync et de droiddeck-fsync. Fonctionne avec Proton Experimental ARM64, GE-Proton et proton-cachyos. Prendra effet à la prochaine session. droiddeck-esync droiddeck-fsync en priorité Exécute le fsync propre à Proton sur les fichiers Proton d\'origine, avec futex_waitv fourni en espace utilisateur : aucun pack n\'est nécessaire et il continue de fonctionner dès que Steam met à jour Proton. Lorsqu\'il est activé, les jeux l\'utilisent avant droiddeck-esync, sauf les quelques-uns que Proton exclut de fsync. Prendra effet à la prochaine session. diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 113507232..5126c1d6d 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -541,8 +541,8 @@ Turnip: sysmem रेंडरिंग Steam क्लाइंट के लिए तेज़; गेम में ड्राइवर हर पास के लिए टाइल या सिस्टम मेमोरी रेंडरिंग चुनता है। Adreno 710/720/722 पर ज़रूरी। Steam के xalia हेल्पर को छोड़ें - droiddeck-ntsync (एक्सपेरिमेंटल) - जांच के लिए Linux ntsync ड्राइवर की यूज़रस्पेस जगह लेने वाली चीज़। चालू रहते हुए, Proton इसे droiddeck-esync और droiddeck-fsync के बदले इस्तेमाल करता है। यह Proton Experimental ARM64, GE-Proton और proton-cachyos के साथ काम करता है। अगले सेशन से लागू होता है। + droiddeck-ntsync + Linux ntsync ड्राइवर की यूज़रस्पेस जगह लेने वाली चीज़। चालू रहते हुए, Proton इसे droiddeck-esync और droiddeck-fsync के बदले इस्तेमाल करता है। यह Proton Experimental ARM64, GE-Proton और proton-cachyos के साथ काम करता है। अगले सेशन से लागू होता है। droiddeck-esync droiddeck-fsync पहले स्टॉक Proton फ़ाइलों पर Proton का अपना fsync चलाता है, futex_waitv यूज़रस्पेस में दिया जाता है, इसलिए इसे किसी पैक की ज़रूरत नहीं और Steam के Proton अपडेट करते ही यह काम करता रहता है। चालू रहते हुए, गेम इसे droiddeck-esync से पहले इस्तेमाल करते हैं, सिवाय उन कुछ के जिन्हें Proton fsync से दूर रखता है। अगले सेशन से लागू होता है। diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index a1638350a..cbf3a7162 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -541,8 +541,8 @@ Turnip: sysmem renderelés Gyorsabb a Steam kliensnek; játékokban a meghajtó menetenként választ a csempés és a rendszermemóriás renderelés között. Adreno 710/720/722-n szükséges. A Steam xalia segítőjének kihagyása - droiddeck-ntsync (kísérleti) - A Linux ntsync illesztőprogram felhasználói szintű helyettesítője, teszteléshez. Amíg be van kapcsolva, a Proton ezt használja a droiddeck-esync és a droiddeck-fsync helyett. A Proton Experimental ARM64-gyel, a GE-Protonnal és a proton-cachyosszal működik. A következő munkamenettől érvényes. + droiddeck-ntsync + A Linux ntsync illesztőprogram felhasználói szintű helyettesítője. Amíg be van kapcsolva, a Proton ezt használja a droiddeck-esync és a droiddeck-fsync helyett. A Proton Experimental ARM64-gyel, a GE-Protonnal és a proton-cachyosszal működik. A következő munkamenettől érvényes. droiddeck-esync droiddeck-fsync előre A Proton saját fsyncjét futtatja az eredeti Proton-fájlokon, felhasználói szinten biztosított futex_waitv-vel, így nincs szükség csomagra, és azonnal működik, amint a Steam frissíti a Protont. Amíg be van kapcsolva, a játékok ezt használják a droiddeck-esync előtt, kivéve azt a néhányat, amelyet a Proton kizár az fsyncből. A következő munkamenettől érvényes. diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 61bb207a0..444e88819 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -539,8 +539,8 @@ Turnip: rendering sysmem Lebih cepat untuk klien Steam; di gim, driver memilih rendering ubin atau memori sistem untuk setiap pass. Diperlukan pada Adreno 710/720/722. Lewati bantuan xalia milik Steam - droiddeck-ntsync (eksperimental) - Pengganti userspace untuk driver ntsync Linux, untuk pengujian. Saat aktif, Proton menggunakannya sebagai pengganti droiddeck-esync dan droiddeck-fsync. Berfungsi dengan Proton Experimental ARM64, GE-Proton, dan proton-cachyos. Berlaku pada sesi berikutnya. + droiddeck-ntsync + Pengganti userspace untuk driver ntsync Linux. Saat aktif, Proton menggunakannya sebagai pengganti droiddeck-esync dan droiddeck-fsync. Berfungsi dengan Proton Experimental ARM64, GE-Proton, dan proton-cachyos. Berlaku pada sesi berikutnya. droiddeck-esync droiddeck-fsync lebih dulu Menjalankan fsync milik Proton sendiri pada file Proton standar, dengan futex_waitv yang disediakan di userspace, sehingga tidak memerlukan paket dan tetap berfungsi begitu Steam memperbarui Proton. Saat aktif, gim menggunakannya sebelum droiddeck-esync, kecuali beberapa gim yang oleh Proton dijaga tetap di luar fsync. Berlaku pada sesi berikutnya. diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 0d8fd5882..abfe8b6f5 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -543,8 +543,8 @@ Turnip: rendering sysmem Più veloce per il client di Steam; nei giochi il driver sceglie per ogni passata tra rendering a tile e in memoria di sistema. Necessario su Adreno 710/720/722. Salta l\'assistente xalia di Steam - droiddeck-ntsync (sperimentale) - Un sostituto in userspace del driver ntsync di Linux, per i test. Quando è attivo, Proton lo usa al posto di droiddeck-esync e droiddeck-fsync. Funziona con Proton Experimental ARM64, GE-Proton e proton-cachyos. Si applica dalla prossima sessione. + droiddeck-ntsync + Un sostituto in userspace del driver ntsync di Linux. Quando è attivo, Proton lo usa al posto di droiddeck-esync e droiddeck-fsync. Funziona con Proton Experimental ARM64, GE-Proton e proton-cachyos. Si applica dalla prossima sessione. droiddeck-esync Prima droiddeck-fsync Esegue il fsync di Proton sui file Proton originali, con futex_waitv fornito in userspace, quindi non richiede pacchetti e continua a funzionare appena Steam aggiorna Proton. Quando è attivo, i giochi lo usano prima di droiddeck-esync, tranne i pochi che Proton esclude da fsync. Si applica dalla prossima sessione. diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 7b719791f..4431538c7 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -541,8 +541,8 @@ Turnip:sysmemレンダリング Steamクライアントが高速になります。ゲームではドライバーがパスごとにタイルレンダリングとシステムメモリレンダリングを選びます。Adreno 710/720/722では必須です。 Steamのxalia補助をスキップ - droiddeck-ntsync(実験的) - Linuxのntsyncドライバーをユーザー空間で代替するテスト用機能です。オンの間、Protonはdroiddeck-esyncとdroiddeck-fsyncの代わりにこれを使用します。Proton Experimental ARM64、GE-Proton、proton-cachyosで動作します。次回のセッションで有効になります。 + droiddeck-ntsync + Linuxのntsyncドライバーをユーザー空間で代替する機能です。オンの間、Protonはdroiddeck-esyncとdroiddeck-fsyncの代わりにこれを使用します。Proton Experimental ARM64、GE-Proton、proton-cachyosで動作します。次回のセッションで有効になります。 droiddeck-esync droiddeck-fsyncを優先 futex_waitvをユーザー空間で提供し、標準のProtonファイルのままProton自身のfsyncを実行します。パックが不要なため、SteamがProtonを更新してもすぐに動作し続けます。オンの間、Protonがfsyncを使わない一部のゲームを除き、ゲームはdroiddeck-esyncよりこちらを優先します。次回のセッションで有効になります。 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 0ea3c7b1b..c88fe44b3 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -541,8 +541,8 @@ Turnip: sysmem 렌더링 Steam 클라이언트가 빨라집니다. 게임에서는 드라이버가 패스마다 타일 렌더링과 시스템 메모리 렌더링 중 하나를 고릅니다. Adreno 710/720/722에 필수입니다. Steam의 xalia 도우미 건너뛰기 - droiddeck-ntsync(실험적) - 테스트용으로 Linux ntsync 드라이버를 사용자 공간에서 대신하는 기능입니다. 켜져 있으면 Proton이 droiddeck-esync와 droiddeck-fsync 대신 이 기능을 사용합니다. Proton Experimental ARM64, GE-Proton 및 proton-cachyos에서 작동합니다. 다음 세션에 적용됩니다. + droiddeck-ntsync + Linux ntsync 드라이버를 사용자 공간에서 대신하는 기능입니다. 켜져 있으면 Proton이 droiddeck-esync와 droiddeck-fsync 대신 이 기능을 사용합니다. Proton Experimental ARM64, GE-Proton 및 proton-cachyos에서 작동합니다. 다음 세션에 적용됩니다. droiddeck-esync droiddeck-fsync 우선 futex_waitv를 사용자 공간에서 제공하여 기본 Proton 파일로 Proton 자체의 fsync를 실행하므로, 팩이 필요 없고 Steam이 Proton을 업데이트해도 바로 작동합니다. 켜져 있으면 게임이 droiddeck-esync보다 이 기능을 우선 사용합니다. 단, Proton이 fsync를 끄는 일부 게임은 예외입니다. 다음 세션에 적용됩니다. diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index 2e3eb1205..7b44da15b 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -539,8 +539,8 @@ Turnip: rendering sysmem Lebih pantas untuk klien Steam; dalam permainan, pemacu memilih pemaparan jubin atau memori sistem bagi setiap laluan. Diperlukan pada Adreno 710/720/722. Langkau pembantu xalia Steam - droiddeck-ntsync (eksperimen) - Pengganti ruang pengguna untuk pemacu ntsync Linux, untuk pengujian. Semasa ia hidup, Proton menggunakannya sebagai ganti droiddeck-esync dan droiddeck-fsync. Berfungsi dengan Proton Experimental ARM64, GE-Proton dan proton-cachyos. Berkuat kuasa pada sesi seterusnya. + droiddeck-ntsync + Pengganti ruang pengguna untuk pemacu ntsync Linux. Semasa ia hidup, Proton menggunakannya sebagai ganti droiddeck-esync dan droiddeck-fsync. Berfungsi dengan Proton Experimental ARM64, GE-Proton dan proton-cachyos. Berkuat kuasa pada sesi seterusnya. droiddeck-esync droiddeck-fsync dahulu Menjalankan fsync Proton sendiri pada fail Proton standard, dengan futex_waitv dibekalkan dalam ruang pengguna, jadi ia tidak memerlukan pek dan terus berfungsi sebaik sahaja Steam mengemas kini Proton. Semasa ia hidup, permainan menggunakannya mendahului droiddeck-esync, kecuali beberapa Proton yang mematikan fsync. Berkuat kuasa pada sesi seterusnya. diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml index e0ee85176..c702bd959 100644 --- a/app/src/main/res/values-nb/strings.xml +++ b/app/src/main/res/values-nb/strings.xml @@ -541,8 +541,8 @@ Turnip: sysmem-rendering Raskere for Steam-klienten; i spill velger driveren flis- eller systemminnerendering for hvert pass. Påkrevd på Adreno 710/720/722. Hopp over Steams xalia-hjelper - droiddeck-ntsync (eksperimentell) - En erstatning i brukerrom for Linux-driveren ntsync, for testing. Mens dette er på, bruker Proton den i stedet for droiddeck-esync og droiddeck-fsync. Fungerer med Proton Experimental ARM64, GE-Proton og proton-cachyos. Gjelder fra neste sesjon. + droiddeck-ntsync + En erstatning i brukerrom for Linux-driveren ntsync. Mens dette er på, bruker Proton den i stedet for droiddeck-esync og droiddeck-fsync. Fungerer med Proton Experimental ARM64, GE-Proton og proton-cachyos. Gjelder fra neste sesjon. droiddeck-esync droiddeck-fsync først Kjører Protons egen fsync på de uendrede Proton-filene, med futex_waitv levert i brukerrom, så den trenger ingen pakke og fortsetter å fungere i det Steam oppdaterer Proton. Mens dette er på, bruker spill den foran droiddeck-esync, med unntak av de få Proton holder utenfor fsync. Gjelder fra neste sesjon. diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 93b6ab06f..a5fda0867 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -541,8 +541,8 @@ Turnip: sysmem-rendering Sneller voor de Steam-client; in games kiest het stuurprogramma per pass tussen tegel- en systeemgeheugenrendering. Vereist op Adreno 710/720/722. Sla de xalia-helper van Steam over - droiddeck-ntsync (experimenteel) - Een userspace-vervanger voor de ntsync-driver van Linux, voor tests. Terwijl dit aan staat, gebruikt Proton dit in plaats van droiddeck-esync en droiddeck-fsync. Werkt met Proton Experimental ARM64, GE-Proton en proton-cachyos. Geldt vanaf de volgende sessie. + droiddeck-ntsync + Een userspace-vervanger voor de ntsync-driver van Linux. Terwijl dit aan staat, gebruikt Proton dit in plaats van droiddeck-esync en droiddeck-fsync. Werkt met Proton Experimental ARM64, GE-Proton en proton-cachyos. Geldt vanaf de volgende sessie. droiddeck-esync droiddeck-fsync eerst Draait de eigen fsync van Proton op de standaard Proton-bestanden, met futex_waitv in userspace geleverd, zodat er geen pakket nodig is en dit blijft werken zodra Steam Proton bijwerkt. Terwijl dit aan staat, gebruiken games dit vóór droiddeck-esync, behalve de paar die Proton niet op fsync laat draaien. Geldt vanaf de volgende sessie. diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 288e644ba..d13e2f120 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -545,8 +545,8 @@ Turnip: renderowanie sysmem Szybsze dla klienta Steam; w grach sterownik dla każdego przebiegu wybiera renderowanie kafelkowe lub w pamięci systemowej. Wymagane na Adreno 710/720/722. Pomijaj pomocnika xalia w Steam - droiddeck-ntsync (eksperymentalne) - Zastępnik sterownika Linux ntsync w przestrzeni użytkownika, do testów. Gdy jest włączony, Proton używa go zamiast droiddeck-esync i droiddeck-fsync. Zgodny z Proton Experimental ARM64, GE-Proton i proton-cachyos. Działa od następnej sesji. + droiddeck-ntsync + Zastępnik sterownika Linux ntsync w przestrzeni użytkownika. Gdy jest włączony, Proton używa go zamiast droiddeck-esync i droiddeck-fsync. Zgodny z Proton Experimental ARM64, GE-Proton i proton-cachyos. Działa od następnej sesji. droiddeck-esync Najpierw droiddeck-fsync Uruchamia własny fsync Proton na standardowych plikach Proton, z futex_waitv dostarczanym w przestrzeni użytkownika, więc nie wymaga pakietu i działa od razu po aktualizacji Proton przez Steam. Gdy jest włączony, gry używają go przed droiddeck-esync, z wyjątkiem nielicznych, którym Proton wyłącza fsync. Działa od następnej sesji. diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index 91e75d38f..4d05644a0 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -543,8 +543,8 @@ Turnip: renderização em sysmem Mais rápido para o cliente Steam; nos jogos, o controlador escolhe em cada passagem entre renderização em mosaico ou na memória do sistema. Obrigatório no Adreno 710/720/722. Ignorar o assistente xalia do Steam - droiddeck-ntsync (experimental) - Um substituto em espaço de utilizador para o controlador ntsync do Linux, para testes. Enquanto estiver ativado, o Proton utiliza-o em vez do droiddeck-esync e do droiddeck-fsync. Funciona com o Proton Experimental ARM64, o GE-Proton e o proton-cachyos. Aplica-se na próxima sessão. + droiddeck-ntsync + Um substituto em espaço de utilizador para o controlador ntsync do Linux. Enquanto estiver ativado, o Proton utiliza-o em vez do droiddeck-esync e do droiddeck-fsync. Funciona com o Proton Experimental ARM64, o GE-Proton e o proton-cachyos. Aplica-se na próxima sessão. droiddeck-esync droiddeck-fsync primeiro Executa o fsync do próprio Proton sobre os ficheiros originais do Proton, com o futex_waitv fornecido em espaço de utilizador, por isso não precisa de pacote e continua a funcionar assim que o Steam atualiza o Proton. Enquanto estiver ativado, os jogos utilizam-no antes do droiddeck-esync, exceto os poucos que o Proton mantém fora do fsync. Aplica-se na próxima sessão. diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 45bd06550..802fa6ad9 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -543,8 +543,8 @@ Turnip: renderização sysmem Mais rápido para o cliente da Steam; nos jogos, o driver escolhe a cada passagem entre renderização em blocos ou na memória do sistema. Obrigatório nas Adreno 710/720/722. Pular o assistente xalia da Steam - droiddeck-ntsync (experimental) - Um substituto em espaço de usuário para o driver ntsync do Linux, para testes. Enquanto estiver ativado, o Proton o usa no lugar do droiddeck-esync e do droiddeck-fsync. Funciona com o Proton Experimental ARM64, o GE-Proton e o proton-cachyos. Vale a partir da próxima sessão. + droiddeck-ntsync + Um substituto em espaço de usuário para o driver ntsync do Linux. Enquanto estiver ativado, o Proton o usa no lugar do droiddeck-esync e do droiddeck-fsync. Funciona com o Proton Experimental ARM64, o GE-Proton e o proton-cachyos. Vale a partir da próxima sessão. droiddeck-esync droiddeck-fsync primeiro Roda o fsync do próprio Proton nos arquivos originais do Proton, com o futex_waitv fornecido em espaço de usuário, então não precisa de pacote e continua funcionando assim que a Steam atualiza o Proton. Enquanto estiver ativado, os jogos o usam antes do droiddeck-esync, exceto os poucos que o Proton mantém fora do fsync. Vale a partir da próxima sessão. diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 4b434247a..9ed2b820b 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -543,8 +543,8 @@ Turnip: randare sysmem Mai rapidă pentru clientul Steam; în jocuri, driverul alege la fiecare trecere între randarea pe dale și cea în memoria sistemului. Necesară pe Adreno 710/720/722. Omite asistentul xalia al Steam - droiddeck-ntsync (experimental) - Un substitut la nivel de utilizator pentru driverul Linux ntsync, pentru testare. Cât timp este activ, Proton îl folosește în locul droiddeck-esync și droiddeck-fsync. Funcționează cu Proton Experimental ARM64, GE-Proton și proton-cachyos. Se aplică la sesiunea următoare. + droiddeck-ntsync + Un substitut la nivel de utilizator pentru driverul Linux ntsync. Cât timp este activ, Proton îl folosește în locul droiddeck-esync și droiddeck-fsync. Funcționează cu Proton Experimental ARM64, GE-Proton și proton-cachyos. Se aplică la sesiunea următoare. droiddeck-esync droiddeck-fsync primul Rulează propriul fsync al Proton pe fișierele Proton standard, cu futex_waitv furnizat la nivel de utilizator, deci nu are nevoie de pachet și continuă să funcționeze imediat ce Steam actualizează Proton. Cât timp este activ, jocurile îl folosesc înaintea droiddeck-esync, cu excepția celor puține pe care Proton le ține în afara fsync. Se aplică la sesiunea următoare. diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 592685d83..ad24076cc 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -547,8 +547,8 @@ Turnip: рендеринг sysmem Клиент Steam будет быстрее; в играх драйвер для каждого прохода выбирает тайловый рендеринг или рендеринг в системной памяти. Требуется для Adreno 710/720/722. Пропустить помощник xalia в Steam - droiddeck-ntsync (экспериментально) - Замена драйвера ntsync Linux в пространстве пользователя, для тестирования. Пока включено, Proton использует ее вместо droiddeck-esync и droiddeck-fsync. Работает с Proton Experimental ARM64, GE-Proton и proton-cachyos. Вступит в силу в следующей сессии. + droiddeck-ntsync + Замена драйвера ntsync Linux в пространстве пользователя. Пока включено, Proton использует ее вместо droiddeck-esync и droiddeck-fsync. Работает с Proton Experimental ARM64, GE-Proton и proton-cachyos. Вступит в силу в следующей сессии. droiddeck-esync droiddeck-fsync в приоритете Запускает собственный fsync Proton на стандартных файлах Proton, а futex_waitv предоставляется в пространстве пользователя: пакет не нужен, и все продолжает работать сразу после обновления Proton в Steam. Пока включено, игры используют его раньше droiddeck-esync, кроме тех немногих, для которых Proton отключает fsync. Вступит в силу в следующей сессии. diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 5f75e2177..ee40ac576 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -541,8 +541,8 @@ Turnip: sysmem-rendering Snabbare för Steam-klienten; i spel väljer drivrutinen kakel- eller systemminnesrendering för varje pass. Krävs på Adreno 710/720/722. Hoppa över Steams xalia-hjälp - droiddeck-ntsync (experimentell) - En ersättning i användarutrymmet för Linux ntsync-drivrutinen, för testning. Medan det är på använder Proton det i stället för droiddeck-esync och droiddeck-fsync. Fungerar med Proton Experimental ARM64, GE-Proton och proton-cachyos. Gäller från nästa session. + droiddeck-ntsync + En ersättning i användarutrymmet för Linux ntsync-drivrutinen. Medan det är på använder Proton det i stället för droiddeck-esync och droiddeck-fsync. Fungerar med Proton Experimental ARM64, GE-Proton och proton-cachyos. Gäller från nästa session. droiddeck-esync droiddeck-fsync först Kör Protons egen fsync på de ursprungliga Proton-filerna, med futex_waitv tillhandahållen i användarutrymmet, så det behöver inget paket och fortsätter fungera så snart Steam uppdaterar Proton. Medan det är på använder spel det före droiddeck-esync, förutom de få som Proton håller borta från fsync. Gäller från nästa session. diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index d2c04317c..dffe5fd7a 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -539,8 +539,8 @@ Turnip: การเรนเดอร์ sysmem เร็วขึ้นสำหรับไคลเอนต์ Steam ในเกมไดรเวอร์จะเลือกการเรนเดอร์แบบไทล์หรือในหน่วยความจำระบบในแต่ละพาส จำเป็นสำหรับ Adreno 710/720/722 ข้ามตัวช่วย xalia ของ Steam - droiddeck-ntsync (ทดลอง) - ตัวแทนในระดับยูสเซอร์สเปซของไดรเวอร์ ntsync ของ Linux สำหรับการทดสอบ เมื่อเปิดใช้ Proton จะใช้สิ่งนี้แทน droiddeck-esync และ droiddeck-fsync ใช้ได้กับ Proton Experimental ARM64, GE-Proton และ proton-cachyos จะมีผลในเซสชันถัดไป + droiddeck-ntsync + ตัวแทนในระดับยูสเซอร์สเปซของไดรเวอร์ ntsync ของ Linux เมื่อเปิดใช้ Proton จะใช้สิ่งนี้แทน droiddeck-esync และ droiddeck-fsync ใช้ได้กับ Proton Experimental ARM64, GE-Proton และ proton-cachyos จะมีผลในเซสชันถัดไป droiddeck-esync droiddeck-fsync ก่อน รัน fsync ของ Proton เองบนไฟล์ Proton มาตรฐาน โดยมี futex_waitv ที่จัดเตรียมในระดับยูสเซอร์สเปซ จึงไม่ต้องใช้แพ็กและยังคงทำงานได้ทันทีที่ Steam อัปเดต Proton เมื่อเปิดใช้ เกมจะใช้สิ่งนี้ก่อน droiddeck-esync ยกเว้นบางส่วนที่ Proton ปิด fsync ไว้ จะมีผลในเซสชันถัดไป diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 24fc12e92..bb71dab78 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -541,8 +541,8 @@ Turnip: sysmem işleme Steam istemcisi için daha hızlı; oyunlarda sürücü her geçişte döşeme tabanlı veya sistem belleğinde işlemeyi seçer. Adreno 710/720/722\'de gereklidir. Steam\'in xalia yardımcısını atla - droiddeck-ntsync (deneysel) - Linux ntsync sürücüsünün test amaçlı, kullanıcı alanında çalışan bir yedeği. Açıkken Proton, droiddeck-esync ve droiddeck-fsync yerine bunu kullanır. Proton Experimental ARM64, GE-Proton ve proton-cachyos ile çalışır. Sonraki oturumda uygulanır. + droiddeck-ntsync + Linux ntsync sürücüsünün kullanıcı alanında çalışan bir yedeği. Açıkken Proton, droiddeck-esync ve droiddeck-fsync yerine bunu kullanır. Proton Experimental ARM64, GE-Proton ve proton-cachyos ile çalışır. Sonraki oturumda uygulanır. droiddeck-esync Önce droiddeck-fsync Proton\'un kendi fsync\'ini standart Proton dosyalarıyla, futex_waitv kullanıcı alanında sağlanarak çalıştırır; bu yüzden paket gerektirmez ve Steam Proton\'u güncellediği anda da çalışmaya devam eder. Açıkken oyunlar, Proton\'un fsync dışında tuttuğu birkaç oyun hariç, bunu droiddeck-esync\'ten önce kullanır. Sonraki oturumda uygulanır. diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 0fe47636a..a67b973c6 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -545,8 +545,8 @@ Turnip: рендеринг через sysmem Швидше для клієнта Steam; в іграх драйвер для кожного проходу вибирає тайловий рендеринг або рендеринг у системній пам’яті. Потрібно для Adreno 710/720/722. Пропускати помічник Steam xalia - droiddeck-ntsync (експериментально) - Програмна заміна драйвера ntsync ядра Linux у просторі користувача, для тестування. Поки це увімкнено, Proton використовує її замість droiddeck-esync і droiddeck-fsync. Працює з Proton Experimental ARM64, GE-Proton і proton-cachyos. Застосовується в наступному сеансі. + droiddeck-ntsync + Програмна заміна драйвера ntsync ядра Linux у просторі користувача. Поки це увімкнено, Proton використовує її замість droiddeck-esync і droiddeck-fsync. Працює з Proton Experimental ARM64, GE-Proton і proton-cachyos. Застосовується в наступному сеансі. droiddeck-esync droiddeck-fsync першим Запускає власний fsync Proton на стандартних файлах Proton, а futex_waitv надається в просторі користувача, тому не потрібен пакет і все працює відразу після оновлення Proton у Steam. Поки це увімкнено, ігри використовують його раніше за droiddeck-esync, окрім кількох, де Proton тримає fsync вимкненим. Застосовується в наступному сеансі. diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index c7e88e065..73a93bf2f 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -539,8 +539,8 @@ Turnip: render sysmem Nhanh hơn cho ứng dụng Steam; trong game, trình điều khiển chọn kết xuất theo ô hoặc trong bộ nhớ hệ thống cho từng lượt. Bắt buộc trên Adreno 710/720/722. Bỏ qua trình trợ giúp xalia của Steam - droiddeck-ntsync (thử nghiệm) - Một bản thay thế ở không gian người dùng cho driver ntsync của Linux, dùng để thử nghiệm. Khi đang bật, Proton dùng nó thay cho droiddeck-esync và droiddeck-fsync. Hoạt động với Proton Experimental ARM64, GE-Proton và proton-cachyos. Áp dụng từ phiên sau. + droiddeck-ntsync + Một bản thay thế ở không gian người dùng cho driver ntsync của Linux. Khi đang bật, Proton dùng nó thay cho droiddeck-esync và droiddeck-fsync. Hoạt động với Proton Experimental ARM64, GE-Proton và proton-cachyos. Áp dụng từ phiên sau. droiddeck-esync droiddeck-fsync trước Chạy fsync riêng của Proton trên các tệp Proton gốc, với futex_waitv được cung cấp ở không gian người dùng, nên không cần pack và vẫn hoạt động ngay khi Steam cập nhật Proton. Khi đang bật, game dùng nó trước droiddeck-esync, trừ một số ít Proton giữ fsync tắt. Áp dụng từ phiên sau. diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 68d927ebb..6d09ed2ae 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -541,8 +541,8 @@ Turnip:sysmem 渲染 Steam 客户端更快;游戏中驱动会为每个渲染通道选择分块渲染或系统内存渲染。Adreno 710/720/722 必需。 跳过 Steam 的 xalia 助手 - droiddeck-ntsync(实验性) - Linux ntsync 驱动的用户态替代品,供测试使用。开启时,Proton 使用它代替 droiddeck-esync 和 droiddeck-fsync。适用于 Proton Experimental ARM64、GE-Proton 和 proton-cachyos。将在下次会话生效。 + droiddeck-ntsync + Linux ntsync 驱动的用户态替代品。开启时,Proton 使用它代替 droiddeck-esync 和 droiddeck-fsync。适用于 Proton Experimental ARM64、GE-Proton 和 proton-cachyos。将在下次会话生效。 droiddeck-esync droiddeck-fsync 优先 在原始 Proton 文件上运行 Proton 自带的 fsync,由用户态提供 futex_waitv,因此无需任何包,且 Steam 更新 Proton 后立即继续可用。开启时,除 Proton 自身禁用 fsync 的少数游戏外,各游戏优先使用它而非 droiddeck-esync。将在下次会话生效。 diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index a39c7b101..ce0f2693c 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -541,8 +541,8 @@ Turnip:sysmem 渲染 Steam 用戶端會更快;遊戲中驅動程式會為每個渲染通道選擇分塊渲染或系統記憶體渲染。Adreno 710/720/722 必須開啟。 略過 Steam 的 xalia 輔助程式 - droiddeck-ntsync(實驗性) - 在用戶空間代替 Linux ntsync 驅動程式,供測試使用。開啟時,Proton 會使用它而非 droiddeck-esync 和 droiddeck-fsync。適用於 Proton Experimental ARM64、GE-Proton 和 proton-cachyos。下次會話生效。 + droiddeck-ntsync + 在用戶空間代替 Linux ntsync 驅動程式。開啟時,Proton 會使用它而非 droiddeck-esync 和 droiddeck-fsync。適用於 Proton Experimental ARM64、GE-Proton 和 proton-cachyos。下次會話生效。 droiddeck-esync 優先使用 droiddeck-fsync 在原裝 Proton 檔案上執行 Proton 本身的 fsync,並在用戶空間提供 futex_waitv,因此無需套件,Steam 更新 Proton 後亦能立即繼續運作。開啟時,遊戲會優先使用它而非 droiddeck-esync,Proton 刻意不用 fsync 的少數遊戲除外。下次會話生效。 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index d29f3cef4..a7d315ec1 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -541,8 +541,8 @@ Turnip:sysmem 渲染 Steam 用戶端會更快;遊戲中驅動程式會為每個算繪通道選擇分塊算繪或系統記憶體算繪。Adreno 710/720/722 必需。 跳過 Steam 的 xalia 輔助程式 - droiddeck-ntsync(實驗性) - 以使用者空間替代 Linux ntsync 驅動程式,供測試使用。開啟時,Proton 會使用它來取代 droiddeck-esync 和 droiddeck-fsync。適用於 Proton Experimental ARM64、GE-Proton 和 proton-cachyos。下次工作階段生效。 + droiddeck-ntsync + 以使用者空間替代 Linux ntsync 驅動程式。開啟時,Proton 會使用它來取代 droiddeck-esync 和 droiddeck-fsync。適用於 Proton Experimental ARM64、GE-Proton 和 proton-cachyos。下次工作階段生效。 droiddeck-esync 優先使用 droiddeck-fsync 在原版 Proton 檔案上執行 Proton 自己的 fsync,並在使用者空間提供 futex_waitv,因此不需要套件,Steam 更新 Proton 後也能立即繼續運作。開啟時,遊戲會優先使用它而非 droiddeck-esync,Proton 本身停用 fsync 的少數遊戲除外。下次工作階段生效。 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c449a05be..25c5c820b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -554,8 +554,8 @@ Turnip: sysmem rendering Faster for the Steam client; in games the driver picks tile or system-memory rendering per pass. Required on Adreno 710/720/722. Skip Steam\'s xalia helper - droiddeck-ntsync (experimental) - A userspace stand-in for the Linux ntsync driver, for testing. While it is on, Proton uses it instead of droiddeck-esync and droiddeck-fsync. Works with Proton Experimental ARM64, GE-Proton and proton-cachyos. Applies next session. + droiddeck-ntsync + A userspace stand-in for the Linux ntsync driver. While it is on, Proton uses it instead of droiddeck-esync and droiddeck-fsync. Works with Proton Experimental ARM64, GE-Proton and proton-cachyos. Applies next session. droiddeck-esync droiddeck-fsync first Runs Proton\'s own fsync on the stock Proton files, with futex_waitv supplied in userspace, so it needs no pack and keeps working the moment Steam updates Proton. While it is on, games use it ahead of droiddeck-esync, except the few Proton keeps off fsync. Applies next session. diff --git a/app/src/test/java/com/droiddeck/launcher/session/SyncBackendTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SyncBackendTest.kt index 844931bbb..85cf481e9 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/SyncBackendTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/SyncBackendTest.kt @@ -13,9 +13,33 @@ class SyncBackendTest { private val context: Context = RuntimeEnvironment.getApplication() @Test - fun aFreshInstallStartsOnEsync() { - assertEquals(SessionPrefs.SYNC_ESYNC, SessionPrefs.syncBackend(context)) + fun aFreshInstallStartsOnNtsync() { + assertEquals(SessionPrefs.SYNC_NTSYNC, SessionPrefs.syncBackend(context)) assertEquals(false, SessionPrefs.fsyncFirst(context)) + SessionPrefs.applyNtsyncDefault(context) + assertEquals(SessionPrefs.SYNC_NTSYNC, SessionPrefs.syncBackend(context)) + } + + @Test + fun anEarlierChoiceMovesToNtsyncOnce() { + for (earlier in listOf(SessionPrefs.SYNC_ESYNC, SessionPrefs.SYNC_FSYNC, SessionPrefs.SYNC_WINESERVER)) { + context.getSharedPreferences("session", Context.MODE_PRIVATE).edit().clear().commit() + SessionPrefs.setSyncBackend(context, earlier) + SessionPrefs.applyNtsyncDefault(context) + assertEquals(SessionPrefs.SYNC_NTSYNC, SessionPrefs.syncBackend(context)) + } + } + + @Test + fun aChoiceMadeAfterTheMoveIsKept() { + SessionPrefs.setSyncBackend(context, SessionPrefs.SYNC_ESYNC) + SessionPrefs.applyNtsyncDefault(context) + SessionPrefs.setSyncBackend(context, SessionPrefs.SYNC_ESYNC) + SessionPrefs.applyNtsyncDefault(context) + assertEquals(SessionPrefs.SYNC_ESYNC, SessionPrefs.syncBackend(context)) + SessionPrefs.setSyncBackend(context, SessionPrefs.SYNC_WINESERVER) + SessionPrefs.applyNtsyncDefault(context) + assertEquals(SessionPrefs.SYNC_WINESERVER, SessionPrefs.syncBackend(context)) } @Test @@ -34,6 +58,7 @@ class SyncBackendTest { @Test fun pickingATabTurnsSyncBackOn() { + SessionPrefs.setFastSync(context, false) SessionPrefs.setSyncFallback(context, false) SessionPrefs.setFsyncFirst(context, false) assertEquals(SessionPrefs.SYNC_WINESERVER, SessionPrefs.syncBackend(context)) From d111b2b3ddf52c93bd245e579897054368d4531d Mon Sep 17 00:00:00 2001 From: XxJSONDeruloxX Date: Fri, 23 Oct 2026 13:59:04 -0400 Subject: [PATCH 36/59] ci: reuse preload snapshots verified against main builds --- .github/workflows/build.yml | 63 +++--- tools/components/README.md | 35 ++++ tools/components/build-arm64-preloads.sh | 23 +++ tools/components/preloads.lock.json | 125 ++++++++++++ tools/components/preloads.py | 248 +++++++++++++++++++++++ tools/release/runtime_inputs.py | 2 +- tools/tests/test_preload_snapshots.py | 146 +++++++++++++ 7 files changed, 617 insertions(+), 25 deletions(-) create mode 100644 tools/components/README.md create mode 100644 tools/components/build-arm64-preloads.sh create mode 100644 tools/components/preloads.lock.json create mode 100644 tools/components/preloads.py create mode 100644 tools/tests/test_preload_snapshots.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 94dbf6baf..5ea175cbf 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -16,9 +16,15 @@ on: - 'LICENSE' - '.gitignore' workflow_dispatch: + inputs: + rebuild_preloads: + description: Rebuild preloads instead of reusing the approved binary snapshot + type: boolean + default: false permissions: contents: read + actions: read pull-requests: write concurrency: @@ -26,7 +32,7 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' }} env: - PRELOAD_CACHE_VERSION: v7 + PRELOAD_CACHE_VERSION: v8 jobs: xbox: @@ -257,34 +263,43 @@ jobs: app/src/main/assets/linuxfs !app/src/main/assets/linuxfs/usr/local/lib/droiddeck-comet !app/src/main/assets/linuxfs/usr/local/lib/droiddeck-xbox - key: preloads-${{ env.PRELOAD_CACHE_VERSION }}-${{ hashFiles('app/src/main/cpp/fakeinput_steam.cpp', 'app/src/main/cpp/adrenotools/src/hook/kgsl.h', 'tools/quest/*', 'tools/linuxfs/preload/*.c', 'tools/linuxfs/build-x86-preloads.sh', 'tools/linuxfs/fex/*', 'tools/linuxfs/clipboard/*.c', 'tools/linuxfs/ssbs/*.c', 'tools/proot/fastpath/*.c', 'tools/linuxfs/overlay/usr/local/bin/*', 'tools/linuxfs/overlay/usr/bin/**', 'tools/linuxfs/desktop/*', 'tools/gamescope/release.env', 'tools/wlroots/release.env', 'tools/linuxfs/uruntime.env', 'tools/linuxfs/licenses/*', 'tools/mangoapp/*', 'tools/msitools/**', 'tools/linuxfs/debian-image.env', 'tools/pipewire/**', 'tools/ci/install-ubuntu-packages.sh', 'tools/vr/**') }} + !app/src/main/assets/linuxfs/i386/libblsession.so + !app/src/main/assets/linuxfs/i386/libfakeinput.so + !app/src/main/assets/linuxfs/i386/libfaultreport.so + !app/src/main/assets/linuxfs/libblaudit.so + !app/src/main/assets/linuxfs/libblfastpath.so + !app/src/main/assets/linuxfs/libblsession.so + !app/src/main/assets/linuxfs/libfakeinput.so + !app/src/main/assets/linuxfs/libssbs.so + !app/src/main/assets/linuxfs/usr/local/bin/droiddeck-clipboard + !app/src/main/assets/linuxfs/x86_64/libblsession.so + !app/src/main/assets/linuxfs/x86_64/libfakeinput.so + !app/src/main/assets/linuxfs/x86_64/libfaultreport.so + !app/src/main/assets/linuxfs/x86_64/libvulkan-thunk.so + !app/src/main/assets/linuxfs/x86_64/libwebkitinput.so + key: preloads-${{ env.PRELOAD_CACHE_VERSION }}-${{ hashFiles('app/src/main/cpp/adrenotools/src/hook/kgsl.h', 'tools/quest/*', 'tools/linuxfs/overlay/usr/local/bin/*', 'tools/linuxfs/overlay/usr/bin/**', 'tools/linuxfs/desktop/*', 'tools/gamescope/release.env', 'tools/wlroots/release.env', 'tools/linuxfs/uruntime.env', 'tools/linuxfs/licenses/*', 'tools/mangoapp/*', 'tools/msitools/**', 'tools/pipewire/**', 'tools/ci/install-ubuntu-packages.sh', 'tools/vr/**') }} - - name: Build libfakeinput.so and libblsession.so (glibc aarch64) + - name: Restore verified preload snapshots + id: component-preloads + env: + GH_TOKEN: ${{ github.token }} + FORCE_PRELOAD_REBUILD: ${{ inputs.rebuild_preloads || 'false' }} + COMPONENT_TRUSTED_BASE: ${{ needs.xbox.outputs.base-sha || github.sha }} + run: python3 tools/components/preloads.py restore + + - name: Build changed ARM64 preloads + if: steps.component-preloads.outputs.arm64 != 'true' + run: | + bash tools/ci/install-ubuntu-packages.sh gcc-aarch64-linux-gnu g++-aarch64-linux-gnu + bash tools/components/build-arm64-preloads.sh app/src/main/assets/linuxfs + + - name: Prepare remaining Linux runtime assets if: steps.preloads.outputs.cache-hit != 'true' run: | set -euo pipefail bash tools/ci/install-ubuntu-packages.sh gcc-aarch64-linux-gnu g++-aarch64-linux-gnu libvulkan1 mesa-vulkan-drivers D=app/src/main/assets/linuxfs mkdir -p "$D" - aarch64-linux-gnu-g++ -shared -fPIC -O2 -Wall -Wno-attributes -Wno-nonnull-compare \ - -pthread -std=c++17 -static-libstdc++ -static-libgcc -Wl,--exclude-libs,ALL \ - -o "$D/libfakeinput.so" app/src/main/cpp/fakeinput_steam.cpp -ldl - aarch64-linux-gnu-strip --strip-unneeded "$D/libfakeinput.so" - aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -pthread \ - -o "$D/libblsession.so" tools/linuxfs/preload/*.c -ldl - aarch64-linux-gnu-strip --strip-unneeded "$D/libblsession.so" - aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -pthread \ - -o "$D/libblfastpath.so" tools/proot/fastpath/fastpath.c -ldl - aarch64-linux-gnu-strip --strip-unneeded "$D/libblfastpath.so" - aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -nostdlib -ffreestanding -fno-stack-protector \ - -fno-tree-loop-distribute-patterns -Wl,-z,defs -o "$D/libblaudit.so" tools/proot/fastpath/audit.c - aarch64-linux-gnu-strip --strip-unneeded "$D/libblaudit.so" - aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -pthread \ - -o "$D/libssbs.so" tools/linuxfs/ssbs/ssbs_adapter.c -ldl - aarch64-linux-gnu-strip --strip-unneeded "$D/libssbs.so" - mkdir -p "$D/usr/local/bin" - aarch64-linux-gnu-gcc -O2 -Wall -Wextra -o "$D/usr/local/bin/droiddeck-clipboard" tools/linuxfs/clipboard/clipboard.c -ldl - aarch64-linux-gnu-strip --strip-unneeded "$D/usr/local/bin/droiddeck-clipboard" VK_INCLUDE=$(ls -d "${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}"/toolchains/llvm/prebuilt/*/sysroot/usr/include | head -1) \ tools/vr/build.sh --native "$D/usr/local/lib/droiddeck-vr" for script in tools/linuxfs/overlay/usr/local/bin/droiddeck-* tools/linuxfs/overlay/usr/local/bin/steam-compatibility; do @@ -366,7 +381,7 @@ jobs: ls -lR "$D" - name: Fetch the Debian image the preloads build in - if: steps.preloads.outputs.cache-hit != 'true' + if: steps.component-preloads.outputs.x86 != 'true' env: GH_TOKEN: ${{ github.token }} run: | @@ -389,7 +404,7 @@ jobs: docker image ls "$image" - name: Build the x86 preloads FEX guests load (glibc 2.31) - if: steps.preloads.outputs.cache-hit != 'true' + if: steps.component-preloads.outputs.x86 != 'true' run: | set -euo pipefail docker run --rm -v "$PWD:/src" -w /src public.ecr.aws/docker/library/debian:bullseye bash -c ' diff --git a/tools/components/README.md b/tools/components/README.md new file mode 100644 index 000000000..646c099cc --- /dev/null +++ b/tools/components/README.md @@ -0,0 +1,35 @@ +# Verified preload snapshots + +`preloads.lock.json` pins a small immutable preload archive. It records each +binary's hash, its producing main CI run, the original APK artifact identity and +digest, source/header hashes, the ARM64 build recipe and x86 compiler-image inputs. +It covers core ARM64 preloads and the x86 preload libraries. VR, Android native +libraries, Comet and Xbox keep their existing preparation paths. + +Every build compares current source inputs, including transitive quoted headers, +and build recipes with this manifest. Changed groups rebuild from source. A +missing release also selects a rebuild. Corrupt archives, unexpected files and +binary hash mismatches fail the build. Existing preload checks, runtime packaging +checks and all test jobs still run. + +The trust anchor is the manifest in the pinned main base. A new or changed PR +manifest is independently checked against a successful `push` build of main: +GitHub must confirm the run/source/workflow and original APK artifact digest, and +every claimed binary must match that APK. The claimed source inputs and recipe +must match that run's Git source. A PR cannot establish trust by changing both a +binary and its claimed checksum. This is approved-build reuse, not a claim of +bit-for-bit reproducible compilation or a signed SLSA attestation. + +To refresh after source or compiler changes: + +1. Let a main build compile and test the new inputs. +2. Run `python tools/components/preloads.py snapshot --run RUN_ID`. +3. Publish `app/build/component-snapshot/preloads.tar.gz` to + DroidDeck-Components as `preloads-`, without replacing an + existing release. Include the manifest and producing run in the release notes. +4. Review and commit the updated manifest. CI independently checks its origin. + +The workflow dispatch `rebuild_preloads` option forces source compilation even +when an approved snapshot matches. Use it when refreshing a compiler or system +header revision. Runner image updates do not silently replace an approved binary; +the producing run remains the record of its compiler environment. diff --git a/tools/components/build-arm64-preloads.sh b/tools/components/build-arm64-preloads.sh new file mode 100644 index 000000000..a38a1edaa --- /dev/null +++ b/tools/components/build-arm64-preloads.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail +D=${1:?output directory} +mkdir -p "$D" +aarch64-linux-gnu-g++ -shared -fPIC -O2 -Wall -Wno-attributes -Wno-nonnull-compare \ + -pthread -std=c++17 -static-libstdc++ -static-libgcc -Wl,--exclude-libs,ALL \ + -o "$D/libfakeinput.so" app/src/main/cpp/fakeinput_steam.cpp -ldl +aarch64-linux-gnu-strip --strip-unneeded "$D/libfakeinput.so" +aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -pthread \ + -o "$D/libblsession.so" tools/linuxfs/preload/*.c -ldl +aarch64-linux-gnu-strip --strip-unneeded "$D/libblsession.so" +aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -pthread \ + -o "$D/libblfastpath.so" tools/proot/fastpath/fastpath.c -ldl +aarch64-linux-gnu-strip --strip-unneeded "$D/libblfastpath.so" +aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -nostdlib -ffreestanding -fno-stack-protector \ + -fno-tree-loop-distribute-patterns -Wl,-z,defs -o "$D/libblaudit.so" tools/proot/fastpath/audit.c +aarch64-linux-gnu-strip --strip-unneeded "$D/libblaudit.so" +aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -pthread \ + -o "$D/libssbs.so" tools/linuxfs/ssbs/ssbs_adapter.c -ldl +aarch64-linux-gnu-strip --strip-unneeded "$D/libssbs.so" +mkdir -p "$D/usr/local/bin" +aarch64-linux-gnu-gcc -O2 -Wall -Wextra -o "$D/usr/local/bin/droiddeck-clipboard" tools/linuxfs/clipboard/clipboard.c -ldl +aarch64-linux-gnu-strip --strip-unneeded "$D/usr/local/bin/droiddeck-clipboard" diff --git a/tools/components/preloads.lock.json b/tools/components/preloads.lock.json new file mode 100644 index 000000000..4093912e3 --- /dev/null +++ b/tools/components/preloads.lock.json @@ -0,0 +1,125 @@ +{ + "archive_sha256": "cb6487a94f25a3068fd07958174eb5b5e14f709655ff4ef0a8cdbd99138513b2", + "arm64_recipe": "62c8b9cf001f1b04b52856bd74d353842a4be29960f033067f34c7d3a9a48681", + "groups": { + "arm64": { + "@runner": "84f825635599f81ed33f62c5fc4c73f66044d1468b060d65f83855eb9c8bfe77", + "app/src/main/cpp/adrenotools/src/hook/kgsl.h": "396c56761034a4ed783543ee3312c6ec64d63b2a1cf7eecf1939c23251f25adf", + "app/src/main/cpp/fakeinput_steam.cpp": "3d11e859ca58a42a9da92a3fc589d404df790e151f7e18ea6114645750e70ef5", + "app/src/main/cpp/venc/venc_proto.h": "2242ddbc8981d14a33bac70264bb3e7f701fd1c3ebb988e17bbb2b19b6b808f7", + "tools/linuxfs/clipboard/clipboard.c": "96291c2bbc040c83caabf059ab3e95a9ed90d6a768c759d7cb4c0b64706bddfa", + "tools/linuxfs/preload/binfmt.c": "0933cc49cb53f0aa1206e2eae516d38dc3337e74174be07e10619c42aa56792b", + "tools/linuxfs/preload/dircache.c": "8e7f19ea4a1198783d53a3f357326c6177da157b0cc7e0d22e1670963e1c26e7", + "tools/linuxfs/preload/drm.c": "e0eabab0d8ed9e841918e827bf87168efa7f2dfb8404169e9bed3ab5eda2836c", + "tools/linuxfs/preload/fsync.c": "795c114c2a05d3f250d07145d04c5658091699da3b96290da36348ccadfb0c09", + "tools/linuxfs/preload/inputudev.c": "2648248d3d235a582eb8459709e0a85de537c03ac10c5040fbfb50d9846c68eb", + "tools/linuxfs/preload/kgslid.c": "88a7875fd5289930204d155d92ac9d15492cd06db16ed87aa9c95af37f3e7089", + "tools/linuxfs/preload/kgslpoll.c": "f0d853d919a52487c04cbda5fb3fa22269da54b7f4ee2f7dd3b5bd9909418670", + "tools/linuxfs/preload/links.c": "b7bbcf9112393b152f358fe6f8dc9a1955f0c8532e1eddf016a971415cd13c64", + "tools/linuxfs/preload/lock.c": "f71b3fd40e6a2b74908d3cdc601e8748699ae9c7a89b542d2445b999015ffd66", + "tools/linuxfs/preload/net.c": "335853c8ea0e9a8ebbc5a5500dc4bded9c34c4310a6ab8aa46b07d5ce1c2b55e", + "tools/linuxfs/preload/netif.c": "cc26d50b8d4639d8dd4697811bbf85c9d9f31761f9504a4af42473de3cb22b3c", + "tools/linuxfs/preload/noexec.c": "1855aeaeda3d506329bd9af1107580c74d1f56787c411b2e01c0a8a9d4059c7b", + "tools/linuxfs/preload/ntsync.c": "0614c48af0c062bfcb4c30141efe1b792b340767a67d2fe92ee9877d2ba16e0d", + "tools/linuxfs/preload/opens.c": "92ae4aea16aa457a898dedf4a70eb332d80d3b1e77f3a60bace7715a4317399e", + "tools/linuxfs/preload/pathcache.c": "eb0d4b5238fe6c8891e805d90bd837e466754100c978fad2d0ff717116b9b8a3", + "tools/linuxfs/preload/perms.c": "f600619c4ca130f69001ed7ad7bd74a3dc215b14c61479c45bb770f916d244b9", + "tools/linuxfs/preload/pulseguard.c": "57674ea40283e53a27bc78362de1ba41ee972d172ea38b1f818b1d6eed0abe65", + "tools/linuxfs/preload/quest_kgsl.c": "59d9b8416be6a1c680f1d1e4c2427d2cbb7b415344261adb357d9d211ec95cfe", + "tools/linuxfs/preload/robust.c": "9eb6c0ed50e0f48a8cfb86bd92587f471354bdf2531b9177f4b3ab83bdfed39a", + "tools/linuxfs/preload/rptouch.c": "0e3f8bf2f9a41324361f38fa4da8d2d47593c9555f0cabf8228767397846c7c3", + "tools/linuxfs/preload/space.c": "118389bcc89dfa2b4230b80915e21d2d4331c4cff1f5a58265f85e6ecb5c29d2", + "tools/linuxfs/preload/storage.c": "08a8b46c3588f3160bb8608489768010ec36d5ccdb2b25a431ec9d6fcd413a6e", + "tools/linuxfs/preload/sysv.c": "49774ee5e2e39b84dcfadf63abdc741b3df236cb11783744a1280adee36965e8", + "tools/linuxfs/preload/tracefs.c": "be3f8a2f032b56e6e08a931e77a9386aea4bb93421a482271e06386ec0c469b6", + "tools/linuxfs/preload/tracer.c": "f34e5377c891b0a8d00489245d66f96bc352df4cd90f47a951dcadae980af4c1", + "tools/linuxfs/preload/udevmon.c": "2045dfb883cfb6e2016c359a3dc3af17f3b8f383f6dd0d5dfee407ef0d344852", + "tools/linuxfs/preload/usr2.c": "6ae704380b45a9b3a74c4fe2b3ce4482470dae59ced26cf0aee64f4b4dcc11f9", + "tools/linuxfs/preload/venc.c": "24c3c46fe49d28adfc8c977e52c9cc407f534b26f56d94b3425b8bb01803dba8", + "tools/linuxfs/preload/vmwrite.c": "1a3ea2ec4354e0716c17f7db537b19ab82bcd524f2aece2fa5dec89b076a2188", + "tools/linuxfs/preload/webhelper.c": "a8213a7a96b407459c83fb93365cfe58fd05964e7bb262b8061c1c0792a6aac8", + "tools/linuxfs/preload/writewatch.c": "61e395d1caa184d329dead0e36c4e92cb1069b1154ac36581f37406c1c242240", + "tools/linuxfs/preload/xmode.c": "9a09995bf52d40b07f16828bcc545b59b8fb1ba60d9cb09f14f7f31dba1e65a6", + "tools/linuxfs/preload/zram.c": "12bcf07459728d4a7ede6fa0fea8deb330f7c5d0f0dd61e610e81b9efecd4262", + "tools/linuxfs/ssbs/ssbs_adapter.c": "9e4e0fe637f7b8f91f12d8151f02e8d31bcb4294be2b2c62d3cd3ce76f200306", + "tools/proot/fastpath/audit.c": "bfe347a2ddaceef40a78d7d590e4e679724ee4c82f3f1f1e3c7017a18ad536d4", + "tools/proot/fastpath/fastpath.c": "4cd07b25ba14f6faa6bac5091ae323ded6b646dbb34b9e5a92ea6385e4d37cb5", + "tools/quest/kgsl_compat.c": "111afffda7a6df2469610f6c342ae086b1a97a559c586efc4a28015b86efd6bd", + "tools/quest/kgsl_compat.h": "2e9e0eb647530a35277954b7e61a22ee958067fe46056a608fb32f40f273f1bf" + }, + "x86": { + "@runner": "84f825635599f81ed33f62c5fc4c73f66044d1468b060d65f83855eb9c8bfe77", + "app/src/main/cpp/adrenotools/src/hook/kgsl.h": "396c56761034a4ed783543ee3312c6ec64d63b2a1cf7eecf1939c23251f25adf", + "app/src/main/cpp/fakeinput_steam.cpp": "3d11e859ca58a42a9da92a3fc589d404df790e151f7e18ea6114645750e70ef5", + "app/src/main/cpp/venc/venc_proto.h": "2242ddbc8981d14a33bac70264bb3e7f701fd1c3ebb988e17bbb2b19b6b808f7", + "tools/linuxfs/build-x86-preloads.sh": "d0820252809881db89db5a17427e74b9aa4964aae70623cb7f4b474e90c7a23b", + "tools/linuxfs/debian-image.env": "1c79dea4314c784042a5c59208765c8401771098f293185d61a157d89edf6e52", + "tools/linuxfs/fex/faultreport.c": "573bf780f32f7256dc2fbf40fe850d67e67c354509fdedcac3ff7f1a261daac3", + "tools/linuxfs/fex/vulkanpin.c": "f2693ac2be8d15f7881115e61b1c6b3fe4aeec8f603584c6fce5b0f046563d51", + "tools/linuxfs/fex/webkitinput.c": "693ffd6c8c8c97e87156444ad2255c84dbf22cd0053d135fb8d9be094b5534d1", + "tools/linuxfs/preload/binfmt.c": "0933cc49cb53f0aa1206e2eae516d38dc3337e74174be07e10619c42aa56792b", + "tools/linuxfs/preload/dircache.c": "8e7f19ea4a1198783d53a3f357326c6177da157b0cc7e0d22e1670963e1c26e7", + "tools/linuxfs/preload/drm.c": "e0eabab0d8ed9e841918e827bf87168efa7f2dfb8404169e9bed3ab5eda2836c", + "tools/linuxfs/preload/fsync.c": "795c114c2a05d3f250d07145d04c5658091699da3b96290da36348ccadfb0c09", + "tools/linuxfs/preload/inputudev.c": "2648248d3d235a582eb8459709e0a85de537c03ac10c5040fbfb50d9846c68eb", + "tools/linuxfs/preload/kgslid.c": "88a7875fd5289930204d155d92ac9d15492cd06db16ed87aa9c95af37f3e7089", + "tools/linuxfs/preload/kgslpoll.c": "f0d853d919a52487c04cbda5fb3fa22269da54b7f4ee2f7dd3b5bd9909418670", + "tools/linuxfs/preload/links.c": "b7bbcf9112393b152f358fe6f8dc9a1955f0c8532e1eddf016a971415cd13c64", + "tools/linuxfs/preload/lock.c": "f71b3fd40e6a2b74908d3cdc601e8748699ae9c7a89b542d2445b999015ffd66", + "tools/linuxfs/preload/net.c": "335853c8ea0e9a8ebbc5a5500dc4bded9c34c4310a6ab8aa46b07d5ce1c2b55e", + "tools/linuxfs/preload/netif.c": "cc26d50b8d4639d8dd4697811bbf85c9d9f31761f9504a4af42473de3cb22b3c", + "tools/linuxfs/preload/noexec.c": "1855aeaeda3d506329bd9af1107580c74d1f56787c411b2e01c0a8a9d4059c7b", + "tools/linuxfs/preload/ntsync.c": "0614c48af0c062bfcb4c30141efe1b792b340767a67d2fe92ee9877d2ba16e0d", + "tools/linuxfs/preload/opens.c": "92ae4aea16aa457a898dedf4a70eb332d80d3b1e77f3a60bace7715a4317399e", + "tools/linuxfs/preload/pathcache.c": "eb0d4b5238fe6c8891e805d90bd837e466754100c978fad2d0ff717116b9b8a3", + "tools/linuxfs/preload/perms.c": "f600619c4ca130f69001ed7ad7bd74a3dc215b14c61479c45bb770f916d244b9", + "tools/linuxfs/preload/pulseguard.c": "57674ea40283e53a27bc78362de1ba41ee972d172ea38b1f818b1d6eed0abe65", + "tools/linuxfs/preload/quest_kgsl.c": "59d9b8416be6a1c680f1d1e4c2427d2cbb7b415344261adb357d9d211ec95cfe", + "tools/linuxfs/preload/robust.c": "9eb6c0ed50e0f48a8cfb86bd92587f471354bdf2531b9177f4b3ab83bdfed39a", + "tools/linuxfs/preload/rptouch.c": "0e3f8bf2f9a41324361f38fa4da8d2d47593c9555f0cabf8228767397846c7c3", + "tools/linuxfs/preload/space.c": "118389bcc89dfa2b4230b80915e21d2d4331c4cff1f5a58265f85e6ecb5c29d2", + "tools/linuxfs/preload/storage.c": "08a8b46c3588f3160bb8608489768010ec36d5ccdb2b25a431ec9d6fcd413a6e", + "tools/linuxfs/preload/sysv.c": "49774ee5e2e39b84dcfadf63abdc741b3df236cb11783744a1280adee36965e8", + "tools/linuxfs/preload/tracefs.c": "be3f8a2f032b56e6e08a931e77a9386aea4bb93421a482271e06386ec0c469b6", + "tools/linuxfs/preload/tracer.c": "f34e5377c891b0a8d00489245d66f96bc352df4cd90f47a951dcadae980af4c1", + "tools/linuxfs/preload/udevmon.c": "2045dfb883cfb6e2016c359a3dc3af17f3b8f383f6dd0d5dfee407ef0d344852", + "tools/linuxfs/preload/usr2.c": "6ae704380b45a9b3a74c4fe2b3ce4482470dae59ced26cf0aee64f4b4dcc11f9", + "tools/linuxfs/preload/venc.c": "24c3c46fe49d28adfc8c977e52c9cc407f534b26f56d94b3425b8bb01803dba8", + "tools/linuxfs/preload/vmwrite.c": "1a3ea2ec4354e0716c17f7db537b19ab82bcd524f2aece2fa5dec89b076a2188", + "tools/linuxfs/preload/webhelper.c": "a8213a7a96b407459c83fb93365cfe58fd05964e7bb262b8061c1c0792a6aac8", + "tools/linuxfs/preload/writewatch.c": "61e395d1caa184d329dead0e36c4e92cb1069b1154ac36581f37406c1c242240", + "tools/linuxfs/preload/xmode.c": "9a09995bf52d40b07f16828bcc545b59b8fb1ba60d9cb09f14f7f31dba1e65a6", + "tools/linuxfs/preload/zram.c": "12bcf07459728d4a7ede6fa0fea8deb330f7c5d0f0dd61e610e81b9efecd4262", + "tools/quest/kgsl_compat.c": "111afffda7a6df2469610f6c342ae086b1a97a559c586efc4a28015b86efd6bd", + "tools/quest/kgsl_compat.h": "2e9e0eb647530a35277954b7e61a22ee958067fe46056a608fb32f40f273f1bf" + } + }, + "origin": { + "artifact": 12520502054, + "artifact_sha256": "b7e800a1884a516ec52b050b7f904f9881427bca4579f660b909fd18f15e49ff", + "commit": "b76f13f7acc0af0cc00369dde61fb88e40529e6d", + "run": 39992155294, + "url": "https://github.com/Droid-Deck/DroidDeck/actions/runs/39992155294" + }, + "outputs": { + "i386/libblsession.so": "5343cdb704217ddc81ee593e67d5987363dc7b5ac78bd4bd36000219e5063268", + "i386/libfakeinput.so": "188d88dfb13b6e2f2df948e7b8b4d9ec604770f55d1ea5b6b9e21ad56a74ce5e", + "i386/libfaultreport.so": "95f9ef60f04a13ee60cfbd4d2d2efb60a7063b722e646c78c9dc27162292877b", + "libblaudit.so": "6c6abe89baf5d7309babee0c3372a4b00c2b689e2a63de38c5e9a660626509b5", + "libblfastpath.so": "0e1f151b5c1c5ea9dd380ad58d09f8fe7ba3ae037d0991cb3eef6899e9d5ec83", + "libblsession.so": "29d4b5ea46dba941107e02c0ee544244b137a339cada43bff65c350f2ef2755a", + "libfakeinput.so": "5f6dc3aea05de2dc1fbe0bbfac2ce4d11e3d47f761c6b06c8badb15fe1904392", + "libssbs.so": "c288ed0da26c2481aff270d1cdafe1205061c4a300c2c46ae1b53c6e0c94f44b", + "usr/local/bin/droiddeck-clipboard": "ec50e8f67fdf76372927189b0fdb670f9b70fd3484f2fe3e833bc8f9fdc44205", + "x86_64/libblsession.so": "61d19f06aebab6a789f8c99f687af9903376bfc853e46b70c48a2ce5e497accc", + "x86_64/libfakeinput.so": "8663dd2086bf8ae53069a480dd9ae4619109d32f0947a29e2ce38f07a21ae2e6", + "x86_64/libfaultreport.so": "bb0dc9165d0d764473e552508650edd586df16d620fc5b16b8b1c5740a28ce22", + "x86_64/libvulkan-thunk.so": "74f01b652d7e8179561a096a552328b68da640cc4bad3981edd38e6afbf90243", + "x86_64/libwebkitinput.so": "9b07380a7b017397b9c56bb7bb1161e046b0c6a647fc413888904606b0def6d0" + }, + "toolchain": { + "arm64": "Ubuntu 24.04 GCC cross compiler; exact build commands pinned by arm64_recipe", + "x86": "Debian bullseye compiler image and recipe pinned by source inputs" + }, + "version": 1 +} diff --git a/tools/components/preloads.py b/tools/components/preloads.py new file mode 100644 index 000000000..7da904080 --- /dev/null +++ b/tools/components/preloads.py @@ -0,0 +1,248 @@ +"""Reuse reviewed preload snapshots; changed snapshots must prove their main-build origin.""" +import argparse +import gzip +import hashlib +import io +import json +import os +import posixpath +import re +from pathlib import Path +import subprocess +import tarfile +import urllib.error +import urllib.request +from zipfile import ZipFile + +ROOT = Path(__file__).resolve().parents[2] +REPO = 'Droid-Deck/DroidDeck' +LOCK = 'tools/components/preloads.lock.json' +COMMON = ['app/src/main/cpp/fakeinput_steam.cpp', 'app/src/main/cpp/adrenotools/src/hook/kgsl.h', + 'app/src/main/cpp/venc/venc_proto.h', 'tools/linuxfs/preload', + 'tools/quest/kgsl_compat.c', 'tools/quest/kgsl_compat.h'] +INPUTS = { + 'arm64': COMMON + ['tools/proot/fastpath', 'tools/linuxfs/ssbs', 'tools/linuxfs/clipboard'], + 'x86': COMMON + ['tools/linuxfs/fex', 'tools/linuxfs/build-x86-preloads.sh', 'tools/linuxfs/debian-image.env'], +} +OUTPUTS = { + 'arm64': ['libfakeinput.so', 'libblsession.so', 'libblfastpath.so', 'libblaudit.so', 'libssbs.so', + 'usr/local/bin/droiddeck-clipboard'], + 'x86': [f'{arch}/{name}.so' for arch in ('x86_64', 'i386') + for name in ('libfakeinput', 'libblsession', 'libfaultreport')] + + ['x86_64/libwebkitinput.so', 'x86_64/libvulkan-thunk.so'], +} +FILES = set(sum(OUTPUTS.values(), [])) + + +def sha(data): + return hashlib.sha256(data).hexdigest() + + +def run(*args, root=ROOT): + return subprocess.check_output(args, cwd=root) + + +def api(path): + return json.loads(run('gh', 'api', 'repos/' + REPO + '/' + path)) + + +def source_files(group, root=ROOT, commit=None): + if commit: + paths = run('git', 'ls-tree', '-r', '--name-only', commit, '--', *INPUTS[group], root=root).decode().splitlines() + read = lambda path: run('git', 'show', f'{commit}:{path}', root=root) + else: + paths = run('git', 'ls-files', '--cached', '--others', '--exclude-standard', '--', *INPUTS[group], root=root).decode().splitlines() + read = lambda path: (root / path).read_bytes() + paths = [p for p in paths if not p.endswith('.md') and '__pycache__' not in p and not p.endswith('.pyc')] + if not paths: + raise ValueError('No preload inputs found') + result = {} + pending = list(paths) + while pending: + path = pending.pop() + if path in result: + continue + if path.startswith('../') or path.startswith('/'): + raise ValueError('Preload include escapes repository') + content = read(path).replace(b'\r\n', b'\n') + result[path] = sha(content) + for include in re.findall(rb'^\s*#\s*include\s*"([^"\n]+)"', content, re.M): + pending.append(posixpath.normpath(posixpath.join(posixpath.dirname(path), include.decode()))) + workflow = (run('git', 'show', f'{commit}:.github/workflows/build.yml', root=root).decode() if commit + else (root / '.github/workflows/build.yml').read_text()) + runner = re.search(r'(?ms)^ build:\n.*?^ runs-on: ([^\n]+)', workflow) + if runner is None: + raise ValueError('APK build runner policy is missing') + result['@runner'] = sha(runner[1].strip().encode()) + return dict(sorted(result.items())) + + +def recipe(root=ROOT, commit=None): + def read(path): + return (run('git', 'show', f'{commit}:{path}', root=root).decode() if commit + else (root / path).read_text()) + script = 'tools/components/build-arm64-preloads.sh' + if commit and run('git', 'ls-tree', commit, script, root=root) == b'': + workflow = read('.github/workflows/build.yml') + text = workflow[workflow.index(' aarch64-linux-gnu-g++ -shared'):] + text = text[:text.index(' VK_INCLUDE=')] + else: + text = read(script) + preamble = '#!/usr/bin/env bash\nset -euo pipefail\nD=${1:?output directory}\nmkdir -p "$D"\n' + if not text.startswith(preamble): + return sha(text.encode()) + text = text[len(preamble):] + return sha(' '.join(text.split()).encode()) + + +def artifact_bytes(origin, cache): + build = api(f"actions/runs/{origin['run']}") + if (build['event'] != 'push' or build['head_branch'] != 'main' or build['conclusion'] != 'success' + or build['head_sha'] != origin['commit'] or build['path'] != '.github/workflows/build.yml' + or build['repository']['full_name'] != REPO): + raise ValueError('Snapshot is not from a successful main APK build') + artifact = api(f"actions/artifacts/{origin['artifact']}") + if (artifact['name'] != 'droiddeck-apk' or artifact['workflow_run']['id'] != origin['run'] + or artifact['digest'] != 'sha256:' + origin['artifact_sha256']): + raise ValueError('Original APK artifact identity or digest mismatch') + cache.mkdir(parents=True, exist_ok=True) + path = cache / (origin['artifact_sha256'] + '.zip') + if not path.exists(): + path.write_bytes(run('gh', 'api', f"repos/{REPO}/actions/artifacts/{origin['artifact']}/zip")) + content = path.read_bytes() + if sha(content) != origin['artifact_sha256']: + raise ValueError('Original APK archive checksum mismatch') + with ZipFile(io.BytesIO(content)) as archive: + names = [name for name in archive.namelist() if name.endswith('.apk')] + if len(names) != 1: + raise ValueError('Expected one original APK') + with ZipFile(io.BytesIO(archive.read(names[0]))) as apk: + return {name: apk.read('assets/linuxfs/' + name) for name in FILES} + + +def validate_lock(data): + if data.get('version') != 1 or set(data['groups']) != set(INPUTS): + raise ValueError('Unsupported preload snapshot') + if set(data['outputs']) != FILES: + raise ValueError('Unexpected preload output set') + for digest in [data['archive_sha256'], *data['outputs'].values()]: + if len(digest) != 64 or any(c not in '0123456789abcdef' for c in digest): + raise ValueError('Invalid preload checksum') + + +def prove_origin(data, cache, root=ROOT): + content = artifact_bytes(data['origin'], cache) + if {name: sha(value) for name, value in content.items()} != data['outputs']: + raise ValueError('Snapshot binaries differ from the original main APK') + commit = data['origin']['commit'] + run('git', 'fetch', '--no-tags', 'origin', commit, root=root) + for group in INPUTS: + if source_files(group, root, commit) != data['groups'][group]: + raise ValueError('Snapshot inputs differ from the producing source') + if recipe(root, commit) != data['arm64_recipe']: + raise ValueError('Snapshot recipe differs from the producing source') + + +def unpack(content, data): + if sha(content) != data['archive_sha256']: + raise ValueError('Preload archive checksum mismatch') + result = {} + total = 0 + with tarfile.open(fileobj=io.BytesIO(content), mode='r:gz') as archive: + for entry in archive: + if entry.name not in FILES or entry.name in result or not entry.isfile() or entry.size > 32 * 1024 * 1024: + raise ValueError('Unsafe or unexpected preload archive entry') + total += entry.size + if total > 64 * 1024 * 1024: + raise ValueError('Preload archive exceeds expanded size limit') + value = archive.extractfile(entry).read() + if sha(value) != data['outputs'][entry.name]: + raise ValueError('Preload binary checksum mismatch') + result[entry.name] = value + if set(result) != FILES: + raise ValueError('Incomplete preload archive') + return result + + +def restore(root=ROOT): + data = json.loads((root / LOCK).read_text()) + validate_lock(data) + matching = {group: source_files(group, root) == data['groups'][group] for group in INPUTS} + matching['arm64'] &= recipe(root) == data['arm64_recipe'] + if os.environ.get('FORCE_PRELOAD_REBUILD') == 'true': + matching = dict.fromkeys(INPUTS, False) + if any(matching.values()): + base = os.environ.get('COMPONENT_TRUSTED_BASE') + approved = None + if base: + result = subprocess.run(['git', 'show', f'{base}:{LOCK}'], cwd=root, capture_output=True) + if result.returncode == 0: + approved = json.loads(result.stdout) + if approved != data: + prove_origin(data, root / 'app/build/component-downloads', root) + url = (f"https://github.com/Droid-Deck/DroidDeck-Components/releases/download/" + f"preloads-{data['archive_sha256']}/preloads.tar.gz") + try: + with urllib.request.urlopen(url, timeout=60) as response: + content = response.read(64 * 1024 * 1024) + except urllib.error.HTTPError as error: + if error.code != 404: + raise + print('Preload snapshot is unavailable; rebuilding from source.') + matching = dict.fromkeys(INPUTS, False) + else: + binaries = unpack(content, data) + for group, matches in matching.items(): + if matches: + for name in OUTPUTS[group]: + path = root / 'app/src/main/assets/linuxfs' / name + if not path.resolve().is_relative_to(root.resolve()) or path.is_symlink(): + raise ValueError('Preload output escapes repository or is a symlink') + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(binaries[name]) + path.chmod(0o755 if name.endswith('droiddeck-clipboard') else 0o644) + for group, matches in matching.items(): + print(f"{group}: {'verified snapshot reused' if matches else 'inputs changed or snapshot unavailable; rebuild required'}") + if os.environ.get('GITHUB_OUTPUT'): + with open(os.environ['GITHUB_OUTPUT'], 'a') as output: + output.write(f'{group}={str(matches).lower()}\n') + return matching + + +def snapshot(run_id, destination, root=ROOT): + build = api(f'actions/runs/{run_id}') + candidates = api(f'actions/runs/{run_id}/artifacts?per_page=100')['artifacts'] + artifact = next(a for a in candidates if a['name'] == 'droiddeck-apk') + origin = dict(run=run_id, commit=build['head_sha'], artifact=artifact['id'], + artifact_sha256=artifact['digest'].removeprefix('sha256:'), + url=build['html_url']) + content = artifact_bytes(origin, root / 'app/build/component-downloads') + run('git', 'fetch', '--no-tags', 'origin', origin['commit'], root=root) + data = dict(version=1, origin=origin, groups={g: source_files(g, root, origin['commit']) for g in INPUTS}, + arm64_recipe=recipe(root, origin['commit']), outputs={name: sha(value) for name, value in content.items()}, + toolchain=dict(arm64='Ubuntu 24.04 GCC cross compiler; exact build commands pinned by arm64_recipe', + x86='Debian bullseye compiler image and recipe pinned by source inputs')) + stream = io.BytesIO() + with gzip.GzipFile(fileobj=stream, mode='wb', mtime=0) as compressed: + with tarfile.open(fileobj=compressed, mode='w') as archive: + for name, value in sorted(content.items()): + entry = tarfile.TarInfo(name) + entry.size, entry.mode = len(value), 0o644 + archive.addfile(entry, io.BytesIO(value)) + destination.mkdir(parents=True, exist_ok=True) + (destination / 'preloads.tar.gz').write_bytes(stream.getvalue()) + data['archive_sha256'] = sha(stream.getvalue()) + (root / LOCK).write_text(json.dumps(data, indent=2, sort_keys=True) + '\n') + print('Snapshot SHA-256: ' + data['archive_sha256']) + + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('operation', choices=['restore', 'snapshot']) + parser.add_argument('--run', type=int) + parser.add_argument('--out', type=Path, default=ROOT / 'app/build/component-snapshot') + args = parser.parse_args() + if args.operation == 'restore': + restore() + else: + snapshot(args.run, args.out) diff --git a/tools/release/runtime_inputs.py b/tools/release/runtime_inputs.py index a0ac10bfd..d6cf46bc4 100644 --- a/tools/release/runtime_inputs.py +++ b/tools/release/runtime_inputs.py @@ -23,7 +23,7 @@ PACK_NAMES = ['proot', 'directaudio', 'tracking', 'xbox', 'comet'] SOURCE_PATHS = ['app/build.gradle', 'app/src/main/cpp/fakeinput_steam.cpp', 'app/src/main/assets/pulseaudio.tzst', 'tools/linuxfs', 'tools/proot', 'tools/aaudio-sink', 'tools/directaudio', 'tools/gamescope/release.env', 'tools/droiddeck-esync/release.env', - 'tools/mangoapp', 'tools/msitools', 'tools/pipewire', 'tools/comet', 'tools/xbox', 'tools/build_local.sh', + 'tools/mangoapp', 'tools/msitools', 'tools/pipewire', 'tools/comet', 'tools/xbox', 'tools/components', 'tools/build_local.sh', 'tools/release/runtime_inputs.py', 'app/src/main/assets/desktop-removal', 'tools/quest', 'tools/vr/src', 'tools/vr/driver_droiddeck', 'tools/vr/layer', 'tools/vr/build.sh', 'app/src/main/cpp/waylandcomp/src/headpose_shm.h', diff --git a/tools/tests/test_preload_snapshots.py b/tools/tests/test_preload_snapshots.py new file mode 100644 index 000000000..5a4cfb606 --- /dev/null +++ b/tools/tests/test_preload_snapshots.py @@ -0,0 +1,146 @@ +import copy +import importlib.util +import io +import json +import os +from pathlib import Path +import subprocess +import tarfile +import tempfile +import unittest +from unittest.mock import patch + +SPEC = importlib.util.spec_from_file_location('preload_snapshots', Path(__file__).resolve().parents[1] / 'components/preloads.py') +snapshots = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(snapshots) + + +def archive(files): + stream = io.BytesIO() + with tarfile.open(fileobj=stream, mode='w:gz') as tar: + for name, content in files.items(): + entry = tarfile.TarInfo(name) + entry.size = len(content) + tar.addfile(entry, io.BytesIO(content)) + return stream.getvalue() + + +class PreloadSnapshotTest(unittest.TestCase): + def setUp(self): + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + self.root = Path(directory.name) + # Real repository inputs exercise the transitive include walker and build recipe. + for group in snapshots.INPUTS: + for name in snapshots.source_files(group): + if name.startswith('@'): + continue + target = self.root / name + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes((snapshots.ROOT / name).read_bytes()) + recipe = 'tools/components/build-arm64-preloads.sh' + (self.root / recipe).parent.mkdir(parents=True, exist_ok=True) + (self.root / recipe).write_text((snapshots.ROOT / recipe).read_text()) + (self.root / '.github/workflows').mkdir(parents=True) + (self.root / '.github/workflows/build.yml').write_text((snapshots.ROOT / '.github/workflows/build.yml').read_text()) + self.git('init', '-q') + self.git('config', 'core.autocrlf', 'false') + self.git('config', 'user.name', 'Test Developer') + self.git('config', 'user.email', 'test@example.test') + self.git('add', '.') + self.binaries = {name: ('verified-' + name).encode() for name in snapshots.FILES} + self.content = archive(self.binaries) + self.data = dict(version=1, origin=dict(run=123, commit='abc', artifact=456, artifact_sha256='0' * 64), + groups={g: snapshots.source_files(g, self.root) for g in snapshots.INPUTS}, + arm64_recipe=snapshots.recipe(self.root), archive_sha256=snapshots.sha(self.content), + outputs={name: snapshots.sha(value) for name, value in self.binaries.items()}) + (self.root / snapshots.LOCK).write_text(json.dumps(self.data)) + self.git('add', '.') + self.git('commit', '-qm', 'Approved baseline') + self.base = self.git('rev-parse', 'HEAD').strip() + + def git(self, *args): + return subprocess.check_output(['git', *args], cwd=self.root, text=True) + + def restore(self, base=None): + with patch.dict(os.environ, COMPONENT_TRUSTED_BASE=base or self.base, FORCE_PRELOAD_REBUILD='false'), \ + patch.object(snapshots.urllib.request, 'urlopen', return_value=io.BytesIO(self.content)) as fetch, \ + patch.object(snapshots, 'prove_origin') as prove: + result = snapshots.restore(self.root) + return result, fetch, prove + + def test_reviewed_unchanged_binaries_reused_and_written(self): + result, fetch, prove = self.restore() + self.assertEqual(result, dict(arm64=True, x86=True)) + prove.assert_not_called() + fetch.assert_called_once() + for name, value in self.binaries.items(): + self.assertEqual((self.root / 'app/src/main/assets/linuxfs' / name).read_bytes(), value) + + def test_changed_common_source_requires_both_rebuilds(self): + path = self.root / snapshots.COMMON[0] + path.write_text(path.read_text() + '\n// Changed source\n') + result, fetch, prove = self.restore() + self.assertEqual(result, dict(arm64=False, x86=False)) + fetch.assert_not_called() + prove.assert_not_called() + + def test_new_transitive_header_is_an_input(self): + source = self.root / snapshots.COMMON[0] + source.write_text(source.read_text() + '\n#include "new-header.h"\n') + header = source.with_name('new-header.h') + header.write_text('#define VALUE 1\n') + before = snapshots.source_files('arm64', self.root) + header.write_text('#define VALUE 2\n') + self.assertNotEqual(before, snapshots.source_files('arm64', self.root)) + + def test_changed_recipe_rebuilds_arm64_only(self): + script = self.root / 'tools/components/build-arm64-preloads.sh' + script.write_text(script.read_text().replace('-O2', '-O3')) + result, _, _ = self.restore() + self.assertEqual(result, dict(arm64=False, x86=True)) + + def test_new_manifest_requires_main_build_proof(self): + result, _, prove = self.restore('HEAD~1') + self.assertTrue(all(result.values())) + prove.assert_called_once() + + def test_forged_binary_hash_cannot_prove_main_origin(self): + data = copy.deepcopy(self.data) + data['outputs']['libblsession.so'] = 'f' * 64 + with patch.object(snapshots, 'artifact_bytes', return_value=self.binaries): + with self.assertRaisesRegex(ValueError, 'original main APK'): + snapshots.prove_origin(data, self.root / 'cache', self.root) + + def test_non_main_or_unsuccessful_run_cannot_prove_origin(self): + build = dict(event='pull_request', head_branch='main', conclusion='success', head_sha='abc', + path='.github/workflows/build.yml', repository=dict(full_name=snapshots.REPO)) + for field, value in [('event', 'pull_request'), ('head_branch', 'feature'), ('conclusion', 'failure')]: + candidate = dict(build, event='push') + candidate[field] = value + with self.subTest(field=field), patch.object(snapshots, 'api', return_value=candidate): + with self.assertRaisesRegex(ValueError, 'successful main'): + snapshots.artifact_bytes(self.data['origin'], self.root / 'cache') + + def test_corrupt_archive_is_rejected(self): + with self.assertRaisesRegex(ValueError, 'checksum'): + snapshots.unpack(self.content + b'tampered', self.data) + + def test_missing_duplicate_or_traversal_entries_are_rejected(self): + for files in ({}, {'../escaped': b'bad'}, {'libblsession.so': b'wrong'}): + content = archive(files) + data = dict(self.data, archive_sha256=snapshots.sha(content)) + with self.subTest(names=list(files)), self.assertRaises(ValueError): + snapshots.unpack(content, data) + + def test_missing_release_rebuilds_without_accepting_unverified_bytes(self): + error = snapshots.urllib.error.HTTPError('https://example.test', 404, 'missing', {}, None) + with patch.dict(os.environ, COMPONENT_TRUSTED_BASE=self.base), \ + patch.object(snapshots.urllib.request, 'urlopen', side_effect=error): + self.assertEqual(snapshots.restore(self.root), dict(arm64=False, x86=False)) + + def test_manual_rebuild_does_not_download_snapshot(self): + with patch.dict(os.environ, FORCE_PRELOAD_REBUILD='true'), \ + patch.object(snapshots.urllib.request, 'urlopen') as fetch: + self.assertEqual(snapshots.restore(self.root), dict(arm64=False, x86=False)) + fetch.assert_not_called() From d7bf0b274ff202edd3181dfa2d285cd667b8f2f4 Mon Sep 17 00:00:00 2001 From: XxJSONDeruloxX Date: Fri, 23 Oct 2026 14:31:13 -0400 Subject: [PATCH 37/59] ci: consolidate verified component preparation --- .github/workflows/authorship.yml | 2 +- .github/workflows/build.yml | 718 ++++++------------ tools/ci/resolve-ndk.sh | 16 + tools/ci/toolchains.env | 1 + tools/components/README.md | 34 +- tools/components/preloads.lock.json | 2 +- tools/components/preloads.py | 70 +- tools/components/prepare.py | 167 ++++ tools/components/recipes.json | 225 ++++++ tools/components/recipes/arm64-preloads.sh | 3 + tools/components/recipes/audio.sh | 14 + tools/components/recipes/comet.sh | 5 + tools/components/recipes/debian-image.sh | 18 + tools/components/recipes/directaudio.sh | 3 + tools/components/recipes/esync.sh | 23 + tools/components/recipes/proot.sh | 4 + tools/components/recipes/pulseaudio-source.sh | 5 + tools/components/recipes/runtime.sh | 83 ++ tools/components/recipes/x86-preloads.sh | 17 + tools/components/recipes/xbox.sh | 4 + tools/release/announce-preview.sh | 20 + tools/release/describe-build.sh | 22 + tools/release/publish-ci.sh | 39 + tools/release/report-build.sh | 77 ++ tools/release/runtime_inputs.py | 2 +- tools/tests/test_ci_checks.py | 6 + tools/tests/test_ci_ndk.py | 27 + tools/tests/test_component_prepare.py | 106 +++ tools/tests/test_esync_packs.py | 10 +- tools/tests/test_preload_snapshots.py | 31 + tools/tests/test_xbox_build_source.py | 18 +- 31 files changed, 1269 insertions(+), 503 deletions(-) create mode 100644 tools/ci/resolve-ndk.sh create mode 100644 tools/ci/toolchains.env create mode 100644 tools/components/prepare.py create mode 100644 tools/components/recipes.json create mode 100644 tools/components/recipes/arm64-preloads.sh create mode 100644 tools/components/recipes/audio.sh create mode 100644 tools/components/recipes/comet.sh create mode 100644 tools/components/recipes/debian-image.sh create mode 100644 tools/components/recipes/directaudio.sh create mode 100644 tools/components/recipes/esync.sh create mode 100644 tools/components/recipes/proot.sh create mode 100644 tools/components/recipes/pulseaudio-source.sh create mode 100644 tools/components/recipes/runtime.sh create mode 100644 tools/components/recipes/x86-preloads.sh create mode 100644 tools/components/recipes/xbox.sh create mode 100644 tools/release/announce-preview.sh create mode 100644 tools/release/describe-build.sh create mode 100644 tools/release/publish-ci.sh create mode 100644 tools/release/report-build.sh create mode 100644 tools/tests/test_ci_ndk.py create mode 100644 tools/tests/test_component_prepare.py diff --git a/.github/workflows/authorship.yml b/.github/workflows/authorship.yml index 97c6637a8..ebbcae644 100644 --- a/.github/workflows/authorship.yml +++ b/.github/workflows/authorship.yml @@ -23,7 +23,7 @@ permissions: jobs: validate: - name: Build APK + name: Validate source and build result runs-on: ubuntu-24.04 timeout-minutes: 5 steps: diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5ea175cbf..265b1faee 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -31,17 +31,14 @@ concurrency: group: build-${{ github.event.pull_request.number || github.run_id }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} -env: - PRELOAD_CACHE_VERSION: v8 - jobs: - xbox: - name: Xbox ARM64 backend + source: + name: Pin source tree if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} - runs-on: ubuntu-24.04-arm - timeout-minutes: 30 + runs-on: ubuntu-24.04 + timeout-minutes: 5 outputs: - base-sha: ${{ steps.merge.outputs.base-sha }} + base-sha: ${{ steps.merge.outputs.base-sha || steps.trusted.outputs.base-sha }} tree-sha: ${{ steps.source.outputs.tree-sha }} steps: - uses: actions/checkout@v4 @@ -59,9 +56,46 @@ jobs: git config user.name "ci" git config user.email "ci@users.noreply.github.com" git merge --no-ff --no-edit origin/main - - name: Record backend source tree + - name: Resolve trusted manifest base + id: trusted + if: github.event_name != 'pull_request' + run: | + set -euo pipefail + git fetch --no-tags origin main + echo "base-sha=$(git rev-parse origin/main)" >> "$GITHUB_OUTPUT" + - name: Record pinned source tree id: source run: echo "tree-sha=$(git rev-parse 'HEAD^{tree}')" >> "$GITHUB_OUTPUT" + + xbox: + name: Xbox ARM64 backend + needs: source + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} + runs-on: ubuntu-24.04-arm + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + fetch-depth: 0 + persist-credentials: false + - name: Merge the pinned APK base + if: github.event_name == 'pull_request' + env: + BUILD_BASE_SHA: ${{ needs.source.outputs.base-sha }} + run: | + set -euo pipefail + git fetch --no-tags origin "$BUILD_BASE_SHA" + git config user.name "ci" + git config user.email "ci@users.noreply.github.com" + git merge --no-ff --no-edit FETCH_HEAD + - name: Verify pinned source tree + env: + SOURCE_TREE: ${{ needs.source.outputs.tree-sha }} + run: test "$(git rev-parse 'HEAD^{tree}')" = "$SOURCE_TREE" + - name: Plan component identities + id: plan + run: python3 tools/components/prepare.py plan - uses: actions/cache@v4 id: xbox-cache with: @@ -69,13 +103,10 @@ jobs: app/src/main/assets/linuxfs/usr/local/lib/droiddeck-xbox app/build/xbox/xodus-droiddeck-source.tar.xz app/build/xbox/runtime/*-source.tar.xz - key: xbox-arm64-${{ hashFiles('tools/xbox/**') }} + key: xbox-v2-${{ steps.plan.outputs.xbox }} - name: Build Xbox backend if: steps.xbox-cache.outputs.cache-hit != 'true' - run: | - rustup toolchain install 1.99.0 --profile minimal - bash tools/ci/install-ubuntu-packages.sh build-essential pkg-config libssl-dev protobuf-compiler cmake xz-utils - bash tools/xbox/build.sh + run: python3 tools/components/prepare.py xbox - uses: actions/upload-artifact@v4 with: name: xbox-arm64 @@ -90,8 +121,157 @@ jobs: if-no-files-found: error retention-days: 14 + components: + name: Prepare verified components + needs: source + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} + runs-on: ubuntu-24.04 + timeout-minutes: 40 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + fetch-depth: 0 + persist-credentials: false + - name: Merge the pinned APK base + if: github.event_name == 'pull_request' + env: + BUILD_BASE_SHA: ${{ needs.source.outputs.base-sha }} + run: | + set -euo pipefail + git fetch --no-tags origin "$BUILD_BASE_SHA" + git config user.name "ci" + git config user.email "ci@users.noreply.github.com" + git merge --no-ff --no-edit FETCH_HEAD + - name: Verify pinned source tree + env: + SOURCE_TREE: ${{ needs.source.outputs.tree-sha }} + run: test "$(git rev-parse 'HEAD^{tree}')" = "$SOURCE_TREE" + - name: Resolve the pinned Android NDK + run: bash tools/ci/resolve-ndk.sh + + - name: Plan component identities + id: plan + run: python3 tools/components/prepare.py plan + - name: Cache remaining Linux runtime assets + id: preloads + uses: actions/cache@v4 + with: + path: | + app/src/main/assets/linuxfs + !app/src/main/assets/linuxfs/usr/local/lib/droiddeck-comet + !app/src/main/assets/linuxfs/usr/local/lib/droiddeck-xbox + !app/src/main/assets/linuxfs/i386/libblsession.so + !app/src/main/assets/linuxfs/i386/libfakeinput.so + !app/src/main/assets/linuxfs/i386/libfaultreport.so + !app/src/main/assets/linuxfs/libblaudit.so + !app/src/main/assets/linuxfs/libblfastpath.so + !app/src/main/assets/linuxfs/libblsession.so + !app/src/main/assets/linuxfs/libfakeinput.so + !app/src/main/assets/linuxfs/libssbs.so + !app/src/main/assets/linuxfs/usr/local/bin/droiddeck-clipboard + !app/src/main/assets/linuxfs/x86_64/libblsession.so + !app/src/main/assets/linuxfs/x86_64/libfakeinput.so + !app/src/main/assets/linuxfs/x86_64/libfaultreport.so + !app/src/main/assets/linuxfs/x86_64/libvulkan-thunk.so + !app/src/main/assets/linuxfs/x86_64/libwebkitinput.so + key: runtime-v9-${{ steps.plan.outputs.runtime }} + + - name: Cache PulseAudio 13.0 source + id: pa-src + uses: actions/cache@v4 + with: + path: pa13 + key: pulseaudio-src-${{ steps.plan.outputs.pulseaudio-source }} + - name: Cache proot + id: proot + uses: actions/cache@v4 + with: + path: | + app/src/main/jniLibs/arm64-v8a/libproot.so + app/src/main/jniLibs/arm64-v8a/libproot-loader.so + key: proot-v2-${{ steps.plan.outputs.proot }} + - name: Cache GOG Galaxy companion + id: comet + uses: actions/cache@v4 + with: + path: app/src/main/assets/linuxfs/usr/local/lib/droiddeck-comet + key: comet-v2-${{ steps.plan.outputs.comet }} + + - name: Restore verified preload snapshots + id: component-preloads + env: + GH_TOKEN: ${{ github.token }} + FORCE_PRELOAD_REBUILD: ${{ inputs.rebuild_preloads || 'false' }} + COMPONENT_TRUSTED_BASE: ${{ needs.source.outputs.base-sha }} + run: python3 tools/components/prepare.py preload-snapshot + + - name: Prepare required Linux build tools once + env: + COMPONENTS_TO_BUILD: >- + ${{ steps.preloads.outputs.cache-hit != 'true' && 'runtime' || '' }} + ${{ steps.component-preloads.outputs.arm64 != 'true' && 'arm64-preloads' || '' }} + ${{ steps.comet.outputs.cache-hit != 'true' && 'comet' || '' }} + run: python3 tools/components/prepare.py tools + + - name: Build changed ARM64 preloads + if: steps.component-preloads.outputs.arm64 != 'true' + run: python3 tools/components/prepare.py arm64-preloads + + - name: Prepare remaining Linux runtime assets + if: steps.preloads.outputs.cache-hit != 'true' + run: python3 tools/components/prepare.py runtime + + - name: Fetch the Debian image the preloads build in + if: steps.component-preloads.outputs.x86 != 'true' + env: + GH_TOKEN: ${{ github.token }} + run: python3 tools/components/prepare.py debian-image + + - name: Build the x86 preloads FEX guests load (glibc 2.31) + if: steps.component-preloads.outputs.x86 != 'true' + run: python3 tools/components/prepare.py x86-preloads + + - name: Bundle the droiddeck-esync packs + run: python3 tools/components/prepare.py esync + + - name: Fetch PulseAudio 13.0 source + if: steps.pa-src.outputs.cache-hit != 'true' + run: python3 tools/components/prepare.py pulseaudio-source + + - name: Fetch DirectAudio (tools/directaudio/release.env) + run: python3 tools/components/prepare.py directaudio + + - name: Build the AAudio sink into the audio bundle + run: python3 tools/components/prepare.py audio + + - name: Build proot + if: steps.proot.outputs.cache-hit != 'true' + run: python3 tools/components/prepare.py proot + + - name: Check the preloads + run: | + set -euo pipefail + bash tools/linuxfs/check-preloads.sh app/src/main/assets/linuxfs + + - name: Build GOG Galaxy companion + if: steps.comet.outputs.cache-hit != 'true' + run: python3 tools/components/prepare.py comet + + - name: Prepare graphics driver variants + run: python3 tools/quest/build-driver.py "$ANDROID_HOME/ndk/$NDK_VERSION" + - name: Record and pack prepared components + run: python3 tools/components/prepare.py pack + - uses: actions/upload-artifact@v4 + with: + name: prepared-components + path: app/build/prepared-components.tar.gz + if-no-files-found: error + compression-level: 0 + retention-days: 14 + build: - needs: xbox + needs: [source, components, xbox] name: Compile APK and Android tests if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} runs-on: ubuntu-24.04 @@ -155,7 +335,7 @@ jobs: - name: Merge onto main, or say what conflicts if: github.event_name == 'pull_request' env: - BUILD_BASE_SHA: ${{ needs.xbox.outputs.base-sha }} + BUILD_BASE_SHA: ${{ needs.source.outputs.base-sha }} run: | set -uo pipefail git config user.name "ci" @@ -213,7 +393,7 @@ jobs: - name: Verify backend and APK source match env: - XBOX_SOURCE_TREE: ${{ needs.xbox.outputs.tree-sha }} + XBOX_SOURCE_TREE: ${{ needs.source.outputs.tree-sha }} run: test "$(git rev-parse 'HEAD^{tree}')" = "$XBOX_SOURCE_TREE" - uses: actions/setup-java@v4 @@ -225,11 +405,8 @@ jobs: with: cache-cleanup: on-success - - name: Resolve the runner's NDK - run: | - NDK="${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}" - VERSION=$(sed -n 's/^Pkg.Revision *= *//p' "$NDK/source.properties") - echo "NDK_VERSION=$VERSION" >> "$GITHUB_ENV" + - name: Resolve the pinned Android NDK + run: bash tools/ci/resolve-ndk.sh - name: Set the APK build label id: build-label @@ -255,274 +432,6 @@ jobs: echo "DROIDDECK_PR_NUMBER=${PR_NUMBER:-0}" >> "$GITHUB_ENV" echo "Build: \`$label\`" >> "$GITHUB_STEP_SUMMARY" - - name: Cache the preloads - id: preloads - uses: actions/cache@v4 - with: - path: | - app/src/main/assets/linuxfs - !app/src/main/assets/linuxfs/usr/local/lib/droiddeck-comet - !app/src/main/assets/linuxfs/usr/local/lib/droiddeck-xbox - !app/src/main/assets/linuxfs/i386/libblsession.so - !app/src/main/assets/linuxfs/i386/libfakeinput.so - !app/src/main/assets/linuxfs/i386/libfaultreport.so - !app/src/main/assets/linuxfs/libblaudit.so - !app/src/main/assets/linuxfs/libblfastpath.so - !app/src/main/assets/linuxfs/libblsession.so - !app/src/main/assets/linuxfs/libfakeinput.so - !app/src/main/assets/linuxfs/libssbs.so - !app/src/main/assets/linuxfs/usr/local/bin/droiddeck-clipboard - !app/src/main/assets/linuxfs/x86_64/libblsession.so - !app/src/main/assets/linuxfs/x86_64/libfakeinput.so - !app/src/main/assets/linuxfs/x86_64/libfaultreport.so - !app/src/main/assets/linuxfs/x86_64/libvulkan-thunk.so - !app/src/main/assets/linuxfs/x86_64/libwebkitinput.so - key: preloads-${{ env.PRELOAD_CACHE_VERSION }}-${{ hashFiles('app/src/main/cpp/adrenotools/src/hook/kgsl.h', 'tools/quest/*', 'tools/linuxfs/overlay/usr/local/bin/*', 'tools/linuxfs/overlay/usr/bin/**', 'tools/linuxfs/desktop/*', 'tools/gamescope/release.env', 'tools/wlroots/release.env', 'tools/linuxfs/uruntime.env', 'tools/linuxfs/licenses/*', 'tools/mangoapp/*', 'tools/msitools/**', 'tools/pipewire/**', 'tools/ci/install-ubuntu-packages.sh', 'tools/vr/**') }} - - - name: Restore verified preload snapshots - id: component-preloads - env: - GH_TOKEN: ${{ github.token }} - FORCE_PRELOAD_REBUILD: ${{ inputs.rebuild_preloads || 'false' }} - COMPONENT_TRUSTED_BASE: ${{ needs.xbox.outputs.base-sha || github.sha }} - run: python3 tools/components/preloads.py restore - - - name: Build changed ARM64 preloads - if: steps.component-preloads.outputs.arm64 != 'true' - run: | - bash tools/ci/install-ubuntu-packages.sh gcc-aarch64-linux-gnu g++-aarch64-linux-gnu - bash tools/components/build-arm64-preloads.sh app/src/main/assets/linuxfs - - - name: Prepare remaining Linux runtime assets - if: steps.preloads.outputs.cache-hit != 'true' - run: | - set -euo pipefail - bash tools/ci/install-ubuntu-packages.sh gcc-aarch64-linux-gnu g++-aarch64-linux-gnu libvulkan1 mesa-vulkan-drivers - D=app/src/main/assets/linuxfs - mkdir -p "$D" - VK_INCLUDE=$(ls -d "${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}"/toolchains/llvm/prebuilt/*/sysroot/usr/include | head -1) \ - tools/vr/build.sh --native "$D/usr/local/lib/droiddeck-vr" - for script in tools/linuxfs/overlay/usr/local/bin/droiddeck-* tools/linuxfs/overlay/usr/local/bin/steam-compatibility; do - install -Dm644 "$script" "$D/usr/local/bin/$(basename "$script")" - done - for f in tools/linuxfs/overlay/usr/bin/* tools/linuxfs/overlay/usr/bin/steamos-polkit-helpers/*; do - [ -f "$f" ] && install -Dm644 "$f" "$D/${f#tools/linuxfs/overlay/}" - done - if [ -f tools/gamescope/release.env ]; then - . tools/gamescope/release.env - curl -fsSL --retry 3 -o gamescope.tzst "https://github.com/${GAMESCOPE_REPO:-${GITHUB_REPOSITORY}}/releases/download/${GAMESCOPE_TAG}/gamescope.tzst" - echo "${GAMESCOPE_SHA256} gamescope.tzst" | sha256sum -c - - tar --use-compress-program=unzstd -xf gamescope.tzst -C "$D" - test -f "$D/usr/local/bin/gamescope" - rm gamescope.tzst - fi - . tools/linuxfs/uruntime.env - curl -fsSL --retry 3 -o uruntime "https://github.com/VHSgunzo/uruntime/releases/download/${URUNTIME_VERSION}/${URUNTIME_ASSET}" - echo "${URUNTIME_SHA256} uruntime" | sha256sum -c - - install -Dm644 uruntime "$D/usr/local/lib/droiddeck/uruntime" - install -Dm644 tools/linuxfs/licenses/uruntime-LICENSE "$D/usr/local/share/licenses/uruntime/LICENSE" - rm uruntime - M="$D/usr/local/lib/mangoapp" - mkdir -p "$M" mango-pkgs - grep -v '^#' tools/mangoapp/packages.txt | while read -r sum url; do - [ -n "$url" ] || continue - curl -fsSL --retry 3 -o mango-pkgs/pkg.tar.zst "$url" - echo "$sum mango-pkgs/pkg.tar.zst" | sha256sum -c - - tar --use-compress-program=unzstd -xf mango-pkgs/pkg.tar.zst -C mango-pkgs - rm mango-pkgs/pkg.tar.zst - done - install -m644 mango-pkgs/usr/bin/mangoapp "$M/mangoapp" - for so in libfmt.so.10 libspdlog.so.1.13 libglfw.so.3 libtraceevent.so.1; do - cp -L "mango-pkgs/usr/lib/$so" "$M/$so" - done - aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -Wl,-soname,libtracefs.so.1 \ - -o "$M/libtracefs.so.1" tools/mangoapp/libtracefs-shim.c - aarch64-linux-gnu-strip --strip-unneeded "$M/libtracefs.so.1" - install -Dm644 tools/mangoapp/mangoapp "$D/usr/local/bin/mangoapp" - rm -rf mango-pkgs - ls -l "$M" - mkdir -p pw-pkgs - grep -v '^#' tools/pipewire/packages.txt | while read -r sum url; do - [ -n "$url" ] || continue - curl -fsSL --retry 3 -o pw-pkgs/pkg.tar.zst "$url" - echo "$sum pw-pkgs/pkg.tar.zst" | sha256sum -c - - tar --use-compress-program=unzstd -xf pw-pkgs/pkg.tar.zst -C pw-pkgs - rm pw-pkgs/pkg.tar.zst - done - tools/pipewire/assemble.sh pw-pkgs "$D/usr/local/lib/droiddeck-pipewire.tzst" - rm -rf pw-pkgs - ls -l "$D/usr/local/lib/droiddeck-pipewire.tzst" - T="$D/usr/local/lib/droiddeck-msitools" - mkdir -p "$T" msi-pkgs - grep -v '^#' tools/msitools/packages.txt | while read -r sum url; do - [ -n "$url" ] || continue - curl -fsSL --retry 3 -o msi-pkgs/pkg.tar.xz "$url" - echo "$sum msi-pkgs/pkg.tar.xz" | sha256sum -c - - tar -xJf msi-pkgs/pkg.tar.xz -C msi-pkgs - rm msi-pkgs/pkg.tar.xz - done - install -m644 msi-pkgs/usr/bin/msiinfo "$T/msiinfo" - install -m644 msi-pkgs/usr/bin/cabextract "$T/cabextract" - install -m644 msi-pkgs/usr/lib/7zip/7z "$T/7z" - install -m644 msi-pkgs/usr/lib/7zip/7z.so "$T/7z.so" - for so in libmsi-1.0.so.0 libgsf-1.so.114 libgcab-1.0.so.0; do - cp -L "msi-pkgs/usr/lib/$so" "$T/$so" - done - for f in NOTICE GPL-2 GPL-3 LGPL-2.1 7zip-License 7zip-unRarLicense; do - install -Dm644 "tools/msitools/licenses/$f" "$D/usr/local/share/licenses/droiddeck-msitools/$f" - done - rm -rf msi-pkgs - ls -l "$T" - install -Dm644 tools/linuxfs/desktop/droiddeck-desktop "$D/usr/local/bin/droiddeck-desktop" - install -Dm644 tools/linuxfs/desktop/droiddeck-gpu "$D/usr/local/bin/droiddeck-gpu" - install -Dm644 tools/linuxfs/desktop/droiddeck-desktop-gpu "$D/usr/local/bin/droiddeck-desktop-gpu" - install -Dm644 tools/linuxfs/desktop/kwin_wayland_wrapper "$D/usr/local/bin/kwin_wayland_wrapper" - install -Dm644 tools/linuxfs/desktop/firefox-droiddeck.js "$D/usr/lib/firefox/defaults/pref/droiddeck.js" - ls -lR "$D" - - - name: Fetch the Debian image the preloads build in - if: steps.component-preloads.outputs.x86 != 'true' - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - . tools/linuxfs/debian-image.env - image=public.ecr.aws/docker/library/debian:bullseye - if gh release download "$DEBIAN_IMAGE_TAG" -R "$GITHUB_REPOSITORY" -p "$DEBIAN_IMAGE_ASSET" -O /tmp/debian-image.tar.zst \ - && { [ -z "$DEBIAN_IMAGE_SHA256" ] || echo "$DEBIAN_IMAGE_SHA256 /tmp/debian-image.tar.zst" | sha256sum -c -; }; then - zstd -dc /tmp/debian-image.tar.zst | docker load - rm -f /tmp/debian-image.tar.zst - else - echo "::warning::the hosted Debian image could not be fetched; pulling from the registry instead" - for attempt in 1 2 3 4 5; do - docker pull "$image" && break - [ "$attempt" -lt 5 ] || { echo "::error::could not pull $image after $attempt attempts"; exit 1; } - echo "pull attempt $attempt failed (registry busy?); retrying in $((attempt * 30)) s" - sleep $((attempt * 30)) - done - fi - docker image ls "$image" - - - name: Build the x86 preloads FEX guests load (glibc 2.31) - if: steps.component-preloads.outputs.x86 != 'true' - run: | - set -euo pipefail - docker run --rm -v "$PWD:/src" -w /src public.ecr.aws/docker/library/debian:bullseye bash -c ' - set -euo pipefail - install_compilers() { - apt-get -o Acquire::Check-Valid-Until=false update -qq && - DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends gcc g++ gcc-multilib g++-multilib binutils >/dev/null - } - printf "deb http://deb.debian.org/debian bullseye main\ndeb http://security.debian.org/debian-security bullseye-security main\n" > /etc/apt/sources.list - if ! install_compilers; then - printf "deb http://archive.debian.org/debian bullseye main\ndeb http://archive.debian.org/debian-security bullseye-security main\n" > /etc/apt/sources.list - install_compilers - fi - tools/linuxfs/build-x86-preloads.sh app/src/main/assets/linuxfs - ' - sudo chown -R "$(id -u):$(id -g)" app/src/main/assets/linuxfs - ls -l app/src/main/assets/linuxfs/x86_64 app/src/main/assets/linuxfs/i386 - - - name: Bundle the droiddeck-esync packs - run: | - set -euo pipefail - if [ -f tools/droiddeck-esync/release.env ]; then - . tools/droiddeck-esync/release.env - release_url="https://github.com/${SYNC_BUNDLE_REPO}/releases/download/${SYNC_BUNDLE_TAG}" - curl -fsSL --retry 3 -o sync-bundle.tzst "$release_url/$SYNC_BUNDLE_ASSET" - echo "$SYNC_BUNDLE_SHA256 sync-bundle.tzst" | sha256sum -c - - mkdir -p app/src/main/assets/droiddeck-esync - tar --use-compress-program=unzstd -xf sync-bundle.tzst -C app/src/main/assets/droiddeck-esync - rm sync-bundle.tzst - curl -fsSL --retry 3 -o sync-index.json "$release_url/index.json" - revoked=$(python3 -c 'import json, sys; print(" ".join(p["id"] for p in json.load(open(sys.argv[1]))["packs"] if p.get("revoked") is True))' sync-index.json) - for id in $revoked; do - if [ -e "app/src/main/assets/droiddeck-esync/packs/$id.tzst" ]; then - echo "::warning::$SYNC_BUNDLE_ASSET carries revoked pack $id; it is left out of the APK (point tools/droiddeck-esync/release.env at a newer bundle)" - rm -f "app/src/main/assets/droiddeck-esync/packs/$id.tzst" - fi - done - rm sync-index.json - test -f app/src/main/assets/droiddeck-esync/index.json - test -f app/src/main/assets/droiddeck-esync/index.json.sig - ls -lR app/src/main/assets/droiddeck-esync - fi - - - name: Cache PulseAudio 13.0 source - id: pa-src - uses: actions/cache@v4 - with: - path: pa13 - key: pulseaudio-src-13.0 - - name: Fetch PulseAudio 13.0 source - if: steps.pa-src.outputs.cache-hit != 'true' - run: | - set -euo pipefail - curl -fsSL -o pa.tar.gz https://github.com/pulseaudio/pulseaudio/archive/refs/tags/v13.0.tar.gz - mkdir -p pa13 && tar -xzf pa.tar.gz -C pa13 --strip-components=1 && rm pa.tar.gz - - name: Fetch DirectAudio (tools/directaudio/release.env) - run: tools/directaudio/fetch.sh "$PWD" "$PWD/sink-out" - - name: Build the AAudio sink into the audio bundle - run: | - set -euo pipefail - export NDK="${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}" - tools/aaudio-sink/build.sh "$PWD/pa13" "$PWD/sink-out" - rm -rf pa-bundle && mkdir -p pa-bundle - tar --use-compress-program=unzstd -xf app/src/main/assets/pulseaudio.tzst -C pa-bundle - test ! -f pa-bundle/modules/arm64/module-aaudio-sink.so - install -m755 sink-out/module-aaudio-sink.so pa-bundle/modules/arm64/module-aaudio-sink.so - install -m755 sink-out/module-directaudio-native-sink.so pa-bundle/modules/arm64/module-directaudio-native-sink.so - (cd pa-bundle && tar --use-compress-program='zstd -19' -cf ../pulseaudio.tzst .) - mkdir -p app/build/prepared-assets - mv pulseaudio.tzst app/build/prepared-assets/pulseaudio.tzst - tar --use-compress-program=unzstd -tf app/build/prepared-assets/pulseaudio.tzst | grep -c '\.so$' - rm -rf pa-bundle sink-out - - name: Cache proot - id: proot - uses: actions/cache@v4 - with: - path: | - app/src/main/jniLibs/arm64-v8a/libproot.so - app/src/main/jniLibs/arm64-v8a/libproot-loader.so - key: proot-${{ env.NDK_VERSION }}-${{ hashFiles('tools/proot/**') }} - - name: Build proot - if: steps.proot.outputs.cache-hit != 'true' - run: | - set -euo pipefail - export NDK="${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}" - tools/proot/build.sh app/src/main/jniLibs/arm64-v8a - - - name: Check the preloads - run: | - set -euo pipefail - bash tools/linuxfs/check-preloads.sh app/src/main/assets/linuxfs - - - name: Check Quest tracking IPC - run: | - set -euo pipefail - NDK="${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}" - VK_INCLUDE=$(ls -d "$NDK"/toolchains/llvm/prebuilt/*/sysroot/usr/include | head -1) - mkdir -p build/vr/include - cp -R "$VK_INCLUDE/vulkan" build/vr/include/ - if [ -d "$VK_INCLUDE/vk_video" ]; then cp -R "$VK_INCLUDE/vk_video" build/vr/include/; fi - g++ -std=c++17 -Wall -Wextra -Werror -pthread tools/vr/tests/test_xr_tracking.cpp -o /tmp/test-xr-tracking - /tmp/test-xr-tracking - g++ -std=c++17 -Wall -Wextra -Werror tools/vr/tests/test_xr_touch.cpp -o /tmp/test-xr-touch - /tmp/test-xr-touch - g++ -std=c++17 -Wall -Wextra -Werror tools/vr/tests/test_pad_controller_config.cpp -o /tmp/test-pad-controller-config - /tmp/test-pad-controller-config - g++ -std=c++17 -Wall -Wextra -Werror -pthread tools/vr/tests/test_xr_vsync.cpp -o /tmp/test-xr-vsync - /tmp/test-xr-vsync - g++ -std=c++17 -Wall -Wextra -Werror -pthread tools/vr/tests/test_xr_frames.cpp -o /tmp/test-xr-frames - /tmp/test-xr-frames - cc -Itools/vr/third_party/wayland -c tools/vr/src/wayland_client_stub.c -o /tmp/test-wayland-stub.o - cc -Itools/vr/third_party/wayland -c app/src/main/cpp/waylandcomp/generated/xdg-shell-protocol.c -o /tmp/test-xdg-shell.o - g++ -std=c++17 -Wall -Wextra -Werror -Wno-unused-parameter -Wno-missing-field-initializers -pthread -Ibuild/vr/include \ - -Itools/vr/third_party/wayland -Iapp/src/main/cpp/waylandcomp/generated \ - tools/vr/tests/test_presenter_shutdown.cpp tools/vr/src/presenter.cpp /tmp/test-wayland-stub.o /tmp/test-xdg-shell.o \ - -ldl -o /tmp/test-presenter-shutdown - timeout 10 /tmp/test-presenter-shutdown - - name: Rust toolchain for the store engines (aarch64-linux-android) id: rust run: | @@ -561,28 +470,20 @@ jobs: restore-keys: | cxx-${{ env.NDK_VERSION }}- + - uses: actions/download-artifact@v4 + with: + name: prepared-components + path: app/build + - name: Verify and unpack prepared components + env: + SOURCE_TREE: ${{ needs.source.outputs.tree-sha }} + run: python3 tools/components/prepare.py unpack --tree "$SOURCE_TREE" + - uses: actions/download-artifact@v4 with: name: xbox-arm64 path: app/src/main/assets/linuxfs/usr/local/lib/droiddeck-xbox - - name: Cache GOG Galaxy companion - id: comet - uses: actions/cache@v4 - with: - path: app/src/main/assets/linuxfs/usr/local/lib/droiddeck-comet - key: comet-${{ runner.os }}-${{ runner.arch }}-rust1.90.0-${{ hashFiles('tools/comet/**') }} - - - name: Build GOG Galaxy companion - if: steps.comet.outputs.cache-hit != 'true' - run: | - if ! command -v aarch64-linux-gnu-gcc >/dev/null; then - bash tools/ci/install-ubuntu-packages.sh gcc-aarch64-linux-gnu - fi - rustup toolchain install 1.90.0 --profile minimal - rustup target add --toolchain 1.90.0 aarch64-unknown-linux-gnu - RUSTUP_TOOLCHAIN=1.90.0 bash tools/comet/build.sh - - name: Build the add-on packs run: bash tools/packs/build.sh app/build/packs @@ -593,7 +494,6 @@ jobs: SGDB_API_KEY: ${{ secrets.SGDB_API_KEY }} run: | set -o pipefail - python3 tools/quest/build-driver.py "$ANDROID_HOME/ndk/$NDK_VERSION" python3 tools/release/runtime_inputs.py record ./gradlew assembleRelease --console=plain -PndkVersion="$NDK_VERSION" 2>&1 | tee /tmp/build.log python3 tools/release/check_session_assets.py app/build/outputs/apk/release/app-release.apk @@ -701,87 +601,11 @@ jobs: TEST_DIAGNOSTICS_URL: ${{ steps.unit-test-diagnostics.outputs.artifact-url }} APK_OUTCOME: ${{ steps.apk-upload.outcome }} SAME_REPO: ${{ github.event.pull_request.head.repo.full_name == github.repository }} - run: | - set -uo pipefail - MARK="" - { - echo "$MARK" - echo "## Build of this pull request" - echo - [ -f /tmp/pr-merge.md ] && cat /tmp/pr-merge.md - echo - case "$BUILD_OUTCOME:$UNIT_TEST_OUTCOME:$APK_OUTCOME" in - success:success:success) - echo "### Build passed" - echo - echo "The \`droiddeck-apk\` artifact is [on this run]($RUN_URL), signed with the public test key." - echo - if [ "$SAME_REPO" = true ]; then - echo "After the parallel regression tests pass, a release-signed tester build that installs over Stable or Preview will be published and will appear in DroidDeck's **Test builds** list. Label the PR \`no-test-build\` to skip that." - else - echo "**Maintainer:** add the \`publish-test\` label to publish a release-signed tester build that installs over Stable or Preview and appears in DroidDeck's **Test builds** list. The label is removed automatically after publishing." - fi - echo - echo "Fallback: [open **PR Release Publish**]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/workflows/pr-release-publish.yml) on \`main\` and enter PR \`$PR\`." - ;; - success:success:*) - echo "### APK publication incomplete" - echo - echo "Compilation and unit tests passed, but APK publication did not complete. See [the run]($RUN_URL)." - ;; - success:failure:*) - echo "### ERROR: Unit tests failed" - echo - echo "The APK compiled, but unit tests failed. No installable artifact was published." - echo - if [ -n "$TEST_DIAGNOSTICS_URL" ]; then - echo "The [unit-test-results]($TEST_DIAGNOSTICS_URL) artifact contains the test log and any generated reports." - else - echo "The test diagnostics upload did not complete. See [the test log]($RUN_URL)." - fi - ;; - success:*) - echo "### Build incomplete" - echo - echo "The APK compiled, but unit tests did not complete successfully. See [the run]($RUN_URL)." - ;; - failure:*) - echo "### ERROR: Build failed" - echo - echo "The compiler's errors ([full log]($RUN_URL)):" - echo - echo '```' - { grep -E '^e: |^w: .*error|error:|CMake Error|fatal error|ninja: error|FAILED|What went wrong|Execution failed for task|Unresolved reference|Type mismatch|Unresolved' /tmp/build.log 2>/dev/null \ - | sed -e 's#file:///home/runner/work/[^/]*/[^/]*/##' -e 's#/home/runner/work/[^/]*/[^/]*/##' \ - | grep -v '^w: ' | awk '!seen[$0]++' | head -80; } || true - echo '```' - echo - echo "
What went wrong (Gradle)" - echo - echo '```' - awk '/^\* What went wrong/{p=1} /^\* Try:/{p=0} p{print}' /tmp/build.log 2>/dev/null | head -60 - echo '```' - echo "
" - ;; - skipped:*|cancelled:*) - echo "### Not built" - echo - echo "The build did not run (see above, or [the run]($RUN_URL))." - ;; - esac - echo - } > /tmp/pr-report.md - cat /tmp/pr-report.md >> "$GITHUB_STEP_SUMMARY" - ID=$(gh api "repos/${GITHUB_REPOSITORY}/issues/${PR}/comments" --paginate --jq ".[] | select(.body | startswith(\"$MARK\")) | .id" | head -1 || true) - if [ -n "$ID" ]; then - gh api -X PATCH "repos/${GITHUB_REPOSITORY}/issues/comments/${ID}" -F body=@/tmp/pr-report.md >/dev/null - else - gh api -X POST "repos/${GITHUB_REPOSITORY}/issues/${PR}/comments" -F body=@/tmp/pr-report.md >/dev/null - fi + run: bash tools/release/report-build.sh python-tests: name: Python and native regression tests - needs: xbox + needs: source if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} runs-on: ubuntu-24.04 timeout-minutes: 15 @@ -794,7 +618,7 @@ jobs: - name: Merge the pinned APK base if: github.event_name == 'pull_request' env: - BUILD_BASE_SHA: ${{ needs.xbox.outputs.base-sha }} + BUILD_BASE_SHA: ${{ needs.source.outputs.base-sha }} run: | set -euo pipefail git fetch --no-tags origin "$BUILD_BASE_SHA" @@ -803,11 +627,40 @@ jobs: git merge --no-ff --no-edit FETCH_HEAD - name: Verify backend and test source match env: - XBOX_SOURCE_TREE: ${{ needs.xbox.outputs.tree-sha }} + XBOX_SOURCE_TREE: ${{ needs.source.outputs.tree-sha }} run: test "$(git rev-parse 'HEAD^{tree}')" = "$XBOX_SOURCE_TREE" - name: Install Python test dependencies timeout-minutes: 7 run: bash tools/ci/install-ubuntu-packages.sh python3-yaml gcc-aarch64-linux-gnu libc6-dev-arm64-cross qemu-user libvulkan-dev mesa-vulkan-drivers + - name: Resolve the pinned Android NDK + run: bash tools/ci/resolve-ndk.sh + + - name: Check Quest tracking IPC + run: | + set -euo pipefail + NDK="${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}" + VK_INCLUDE=$(ls -d "$NDK"/toolchains/llvm/prebuilt/*/sysroot/usr/include | head -1) + mkdir -p build/vr/include + cp -R "$VK_INCLUDE/vulkan" build/vr/include/ + if [ -d "$VK_INCLUDE/vk_video" ]; then cp -R "$VK_INCLUDE/vk_video" build/vr/include/; fi + g++ -std=c++17 -Wall -Wextra -Werror -pthread tools/vr/tests/test_xr_tracking.cpp -o /tmp/test-xr-tracking + /tmp/test-xr-tracking + g++ -std=c++17 -Wall -Wextra -Werror tools/vr/tests/test_xr_touch.cpp -o /tmp/test-xr-touch + /tmp/test-xr-touch + g++ -std=c++17 -Wall -Wextra -Werror tools/vr/tests/test_pad_controller_config.cpp -o /tmp/test-pad-controller-config + /tmp/test-pad-controller-config + g++ -std=c++17 -Wall -Wextra -Werror -pthread tools/vr/tests/test_xr_vsync.cpp -o /tmp/test-xr-vsync + /tmp/test-xr-vsync + g++ -std=c++17 -Wall -Wextra -Werror -pthread tools/vr/tests/test_xr_frames.cpp -o /tmp/test-xr-frames + /tmp/test-xr-frames + cc -Itools/vr/third_party/wayland -c tools/vr/src/wayland_client_stub.c -o /tmp/test-wayland-stub.o + cc -Itools/vr/third_party/wayland -c app/src/main/cpp/waylandcomp/generated/xdg-shell-protocol.c -o /tmp/test-xdg-shell.o + g++ -std=c++17 -Wall -Wextra -Werror -Wno-unused-parameter -Wno-missing-field-initializers -pthread -Ibuild/vr/include \ + -Itools/vr/third_party/wayland -Iapp/src/main/cpp/waylandcomp/generated \ + tools/vr/tests/test_presenter_shutdown.cpp tools/vr/src/presenter.cpp /tmp/test-wayland-stub.o /tmp/test-xdg-shell.o \ + -ldl -o /tmp/test-presenter-shutdown + timeout 10 /tmp/test-presenter-shutdown + - name: Steam import and game environment tests run: python3 -m unittest discover -s tools/tests -p 'test_*.py' @@ -857,6 +710,10 @@ jobs: CI_REPO: Droid-Deck/DroidDeck-CI KEEP: 10 steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + persist-credentials: false - uses: actions/download-artifact@v4 with: pattern: droiddeck-signed-* @@ -874,72 +731,13 @@ jobs: - name: Describe the change env: GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - pr=$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls" \ - --jq '[.[] | select(.merged_at != null and .base.ref == "main")] | first // empty') - if [ -n "$pr" ]; then - number=$(jq -r .number <<<"$pr") - title=$(jq -r .title <<<"$pr") - merged=$(jq -r '.merged_at[:10]' <<<"$pr") - summary=$(jq -r '.body // ""' <<<"$pr" | tr -d '\r' | { grep -v '^🤖' || true; } | - awk '!p && /^#/ { next } NF { p = 1; print; next } p { exit }' | head -n 6 | cut -c1-400) - { - echo "**Merged to main** - [PR #$number]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/pull/$number): $title (merged $merged)" - if [ -n "$summary" ]; then - echo - echo "$summary" - fi - } > notes-head.md - else - subject=$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA" --jq '.commit.message | split("\n")[0]') - echo "**Pushed to main** without a PR - $subject" > notes-head.md - fi - cat notes-head.md + run: bash tools/release/describe-build.sh - name: Publish to DroidDeck-CI id: publish env: GH_TOKEN: ${{ steps.ci-token.outputs.token }} - run: | - set -euo pipefail - tag=main-${GITHUB_SHA::7} - cat signed/SHA256SUMS-*.txt > signed/SHA256SUMS.txt - rm signed/SHA256SUMS-*.txt - notes="$(cat notes-head.md) - - Signed build of $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/commit/$GITHUB_SHA - in main, not yet in a DroidDeck release; for testing. - Commit: $GITHUB_SHA - VersionCode: $(sed -n 's/^ *versionCode \([0-9][0-9]*\)$/\1/p' app/build.gradle) - Each apk installs over a DroidDeck release and keeps its data. \`standard\` is the normal app; the others are the same app under another package name, installed beside it. - - SHA-256: - \`\`\` - $(cat signed/SHA256SUMS.txt) - \`\`\`" - gh release delete "$tag" -R "$CI_REPO" --yes --cleanup-tag 2>/dev/null || true - gh release create "$tag" -R "$CI_REPO" --target main --prerelease \ - --title "main ${GITHUB_SHA::7}" --notes "$notes" signed/* - gh release list -R "$CI_REPO" -L 200 --json tagName,publishedAt \ - --jq '[.[] | select(.tagName | startswith("main-"))] | sort_by(.publishedAt) | reverse | .['"$KEEP"':][] | .tagName' | - while IFS= read -r old; do - if [ "$old" = "$tag" ]; then - continue - fi - if ! gh release delete "$old" -R "$CI_REPO" --yes --cleanup-tag 2>/dev/null; then - if gh release view "$old" -R "$CI_REPO" >/dev/null 2>&1; then - echo "::error::Could not delete old release $old" - exit 1 - fi - fi - done - published=$(gh release view "$tag" -R "$CI_REPO" --json tagName,assets) - jq -e --arg tag "$tag" '.tagName == $tag and (.assets | length) > 0' <<< "$published" >/dev/null - url="$GITHUB_SERVER_URL/$CI_REPO/releases/tag/$tag" - apk=$(cd signed && ls DroidDeck-*.apk | awk 'length(a) == 0 || length($0) < length(a) { a = $0 } END { print a }') - echo "release=$url" >> "$GITHUB_OUTPUT" - echo "apk=$GITHUB_SERVER_URL/$CI_REPO/releases/download/$tag/$apk" >> "$GITHUB_OUTPUT" - echo "Published $url" + run: bash tools/release/publish-ci.sh - name: Post the Preview build to Discord env: @@ -947,26 +745,8 @@ jobs: GH_TOKEN: ${{ github.token }} RELEASE: ${{ steps.publish.outputs.release }} APK: ${{ steps.publish.outputs.apk }} - run: | - set -euo pipefail - if [ -z "$WEBHOOK" ]; then - echo "::notice::No DISCORD_BUILDS_WEBHOOK secret; nothing posted" - exit 0 - fi - commit=$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA") - subject=$(jq -r '.commit.message | split("\n")[0]' <<<"$commit") - title=$(jq -r '.commit.message | split("\n") | map(select(length > 0)) | .[1] // ""' <<<"$commit") - pr=$(sed -n 's/^Merge pull request #\([0-9]*\).*/\1/p' <<<"$subject") - if [ -n "$pr" ]; then - what="[#$pr]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/pull/$pr): ${title:-$subject}" - else - what="[\`${GITHUB_SHA::7}\`]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/commit/$GITHUB_SHA): $subject" - fi - get=$(printf 'Download: [standard APK](<%s>) · [all variants](<%s>)' "$APK" "$RELEASE") - content=$(printf '**New signed Preview build** `main-%s`\n%s\nIn the app: **Updates → Preview**\n%s' "${GITHUB_SHA::7}" "$what" "$get") - jq -n --arg content "$content" '{content: $content, allowed_mentions: {parse: []}}' | - curl -fsS -H 'Content-Type: application/json' -d @- "$WEBHOOK" -o /dev/null - echo "Posted: $content" + run: bash tools/release/announce-preview.sh + catalog: needs: announce diff --git a/tools/ci/resolve-ndk.sh b/tools/ci/resolve-ndk.sh new file mode 100644 index 000000000..61df33c50 --- /dev/null +++ b/tools/ci/resolve-ndk.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail +repo=$(cd "$(dirname "$0")/../.." && pwd) +source "$repo/tools/ci/toolchains.env" +ndk="$ANDROID_HOME/ndk/$CI_ANDROID_NDK_VERSION" +if [ ! -f "$ndk/source.properties" ]; then + sdkmanager --sdk_root="$ANDROID_HOME" --install "ndk;$CI_ANDROID_NDK_VERSION" +fi +actual=$(sed -n 's/^Pkg.Revision *= *//p' "$ndk/source.properties" | tr -d '\r') +test "$actual" = "$CI_ANDROID_NDK_VERSION" +{ + echo "NDK_VERSION=$actual" + echo "ANDROID_NDK_HOME=$ndk" + echo "ANDROID_NDK_ROOT=$ndk" + echo "ANDROID_NDK_LATEST_HOME=$ndk" +} >> "$GITHUB_ENV" diff --git a/tools/ci/toolchains.env b/tools/ci/toolchains.env new file mode 100644 index 000000000..ac365f2bb --- /dev/null +++ b/tools/ci/toolchains.env @@ -0,0 +1 @@ +CI_ANDROID_NDK_VERSION=30.0.16248370 diff --git a/tools/components/README.md b/tools/components/README.md index 646c099cc..782d8b9f1 100644 --- a/tools/components/README.md +++ b/tools/components/README.md @@ -1,10 +1,40 @@ -# Verified preload snapshots +# Runtime component preparation + +`recipes.json` owns component source inputs, builders, outputs, package requirements +and pinned downloads. `prepare.py` runs those recipes, calculates cache identities, +installs the union of missing tools and verifies the prepared artifact handoff. +Builders live in `recipes/`; the workflow describes scheduling and gates. + +```mermaid +flowchart LR + Source[Pin source tree] --> Components[Prepare verified components] + Source --> Xbox[Xbox ARM64 backend] + Source --> Tests[Python and native tests] + Components --> APK[Compile APK and Android tests] + Xbox --> APK + APK --> Gate[Compile and test APK gate] + Tests --> Gate + Gate --> Publish[Main only: signing and publishing] +``` + +All workers verify the same source tree. The component artifact records that tree, +recipe identities and every output hash; the APK worker verifies the inventory +before building. This handoff verifies consistency within the same CI run. It is +not independent provenance for arbitrary artifacts. Source policy checks, tests, +APK content/ABI checks and signing/publication gates remain in place. + +`tools/ci/toolchains.env` pins the Android NDK shared by component, APK and native +test workers. Linux builds use Ubuntu 24.04. Recipe and input changes invalidate +the relevant component cache; ordinary caches remain performance aids. + +## Verified preload snapshots `preloads.lock.json` pins a small immutable preload archive. It records each binary's hash, its producing main CI run, the original APK artifact identity and digest, source/header hashes, the ARM64 build recipe and x86 compiler-image inputs. It covers core ARM64 preloads and the x86 preload libraries. VR, Android native -libraries, Comet and Xbox keep their existing preparation paths. +libraries, Comet and Xbox use their registered recipes and existing release pins +or component caches. Every build compares current source inputs, including transitive quoted headers, and build recipes with this manifest. Changed groups rebuild from source. A diff --git a/tools/components/preloads.lock.json b/tools/components/preloads.lock.json index 4093912e3..a92cd6dea 100644 --- a/tools/components/preloads.lock.json +++ b/tools/components/preloads.lock.json @@ -1,6 +1,6 @@ { "archive_sha256": "cb6487a94f25a3068fd07958174eb5b5e14f709655ff4ef0a8cdbd99138513b2", - "arm64_recipe": "62c8b9cf001f1b04b52856bd74d353842a4be29960f033067f34c7d3a9a48681", + "arm64_recipe": "1c92a1b0669277de3e78e9f292634affb5b5a19d5ea49d3ad8610c1dc638c2fb", "groups": { "arm64": { "@runner": "84f825635599f81ed33f62c5fc4c73f66044d1468b060d65f83855eb9c8bfe77", diff --git a/tools/components/preloads.py b/tools/components/preloads.py index 7da904080..671474040 100644 --- a/tools/components/preloads.py +++ b/tools/components/preloads.py @@ -1,6 +1,7 @@ """Reuse reviewed preload snapshots; changed snapshots must prove their main-build origin.""" import argparse import gzip +import http.client import hashlib import io import json @@ -10,6 +11,7 @@ import re from pathlib import Path import subprocess import tarfile +import textwrap import urllib.error import urllib.request from zipfile import ZipFile @@ -17,20 +19,12 @@ from zipfile import ZipFile ROOT = Path(__file__).resolve().parents[2] REPO = 'Droid-Deck/DroidDeck' LOCK = 'tools/components/preloads.lock.json' -COMMON = ['app/src/main/cpp/fakeinput_steam.cpp', 'app/src/main/cpp/adrenotools/src/hook/kgsl.h', - 'app/src/main/cpp/venc/venc_proto.h', 'tools/linuxfs/preload', - 'tools/quest/kgsl_compat.c', 'tools/quest/kgsl_compat.h'] -INPUTS = { - 'arm64': COMMON + ['tools/proot/fastpath', 'tools/linuxfs/ssbs', 'tools/linuxfs/clipboard'], - 'x86': COMMON + ['tools/linuxfs/fex', 'tools/linuxfs/build-x86-preloads.sh', 'tools/linuxfs/debian-image.env'], -} -OUTPUTS = { - 'arm64': ['libfakeinput.so', 'libblsession.so', 'libblfastpath.so', 'libblaudit.so', 'libssbs.so', - 'usr/local/bin/droiddeck-clipboard'], - 'x86': [f'{arch}/{name}.so' for arch in ('x86_64', 'i386') - for name in ('libfakeinput', 'libblsession', 'libfaultreport')] - + ['x86_64/libwebkitinput.so', 'x86_64/libvulkan-thunk.so'], -} +DEFINITIONS = json.loads((ROOT / 'tools/components/recipes.json').read_text()) +INPUTS = {group: DEFINITIONS[name]['source_inputs'] for group, name in + [('arm64', 'arm64-preloads'), ('x86', 'x86-preloads')]} +OUTPUTS = {group: DEFINITIONS[name]['snapshot_outputs'] for group, name in + [('arm64', 'arm64-preloads'), ('x86', 'x86-preloads')]} +COMMON = [name for name in INPUTS['arm64'] if name in INPUTS['x86']] FILES = set(sum(OUTPUTS.values(), [])) @@ -46,12 +40,13 @@ def api(path): return json.loads(run('gh', 'api', 'repos/' + REPO + '/' + path)) -def source_files(group, root=ROOT, commit=None): +def source_files(group, root=ROOT, commit=None, patterns=None): + patterns = patterns if patterns is not None else INPUTS[group] if commit: - paths = run('git', 'ls-tree', '-r', '--name-only', commit, '--', *INPUTS[group], root=root).decode().splitlines() + paths = run('git', 'ls-tree', '-r', '--name-only', commit, '--', *patterns, root=root).decode().splitlines() read = lambda path: run('git', 'show', f'{commit}:{path}', root=root) else: - paths = run('git', 'ls-files', '--cached', '--others', '--exclude-standard', '--', *INPUTS[group], root=root).decode().splitlines() + paths = run('git', 'ls-files', '--cached', '--others', '--exclude-standard', '--', *patterns, root=root).decode().splitlines() read = lambda path: (root / path).read_bytes() paths = [p for p in paths if not p.endswith('.md') and '__pycache__' not in p and not p.endswith('.pyc')] if not paths: @@ -70,7 +65,8 @@ def source_files(group, root=ROOT, commit=None): pending.append(posixpath.normpath(posixpath.join(posixpath.dirname(path), include.decode()))) workflow = (run('git', 'show', f'{commit}:.github/workflows/build.yml', root=root).decode() if commit else (root / '.github/workflows/build.yml').read_text()) - runner = re.search(r'(?ms)^ build:\n.*?^ runs-on: ([^\n]+)', workflow) + job = 'components' if re.search(r'(?m)^ components:', workflow) else 'build' + runner = re.search(r'(?ms)^ ' + job + r':\n.*?^ runs-on: ([^\n]+)', workflow) if runner is None: raise ValueError('APK build runner policy is missing') result['@runner'] = sha(runner[1].strip().encode()) @@ -85,14 +81,14 @@ def recipe(root=ROOT, commit=None): if commit and run('git', 'ls-tree', commit, script, root=root) == b'': workflow = read('.github/workflows/build.yml') text = workflow[workflow.index(' aarch64-linux-gnu-g++ -shared'):] - text = text[:text.index(' VK_INCLUDE=')] + text = textwrap.dedent(text[:text.index(' VK_INCLUDE=')]) else: text = read(script) preamble = '#!/usr/bin/env bash\nset -euo pipefail\nD=${1:?output directory}\nmkdir -p "$D"\n' if not text.startswith(preamble): return sha(text.encode()) text = text[len(preamble):] - return sha(' '.join(text.split()).encode()) + return sha(text.replace('\r\n', '\n').encode()) def artifact_bytes(origin, cache): @@ -130,6 +126,26 @@ def validate_lock(data): raise ValueError('Invalid preload checksum') +def producer_sources(group, commit, root=ROOT): + config = 'tools/components/recipes.json' + if run('git', 'ls-tree', commit, config, root=root): + definitions = json.loads(run('git', 'show', f'{commit}:{config}', root=root)) + name = 'arm64-preloads' if group == 'arm64' else 'x86-preloads' + patterns = definitions[name]['source_inputs'] + else: + # Older approved builds kept ARM64 commands inline, before the Vulkan/VR build. + if group == 'arm64': + text = run('git', 'show', f'{commit}:.github/workflows/build.yml', root=root).decode() + text = text[text.index(' aarch64-linux-gnu-g++ -shared'):] + text = text[:text.index(' VK_INCLUDE=')] + else: + text = run('git', 'show', f'{commit}:tools/linuxfs/build-x86-preloads.sh', root=root).decode() + patterns = re.findall(r'\b((?:tools|app)/[\w./*+-]+\.(?:cpp|c|h))(?=\s|$)', text) + if group == 'x86': + patterns += ['tools/linuxfs/fex', 'tools/linuxfs/build-x86-preloads.sh', 'tools/linuxfs/debian-image.env'] + return source_files(group, root, commit, patterns) + + def prove_origin(data, cache, root=ROOT): content = artifact_bytes(data['origin'], cache) if {name: sha(value) for name, value in content.items()} != data['outputs']: @@ -139,6 +155,9 @@ def prove_origin(data, cache, root=ROOT): for group in INPUTS: if source_files(group, root, commit) != data['groups'][group]: raise ValueError('Snapshot inputs differ from the producing source') + for name, checksum in producer_sources(group, commit, root).items(): + if data['groups'][group].get(name) != checksum: + raise ValueError('Snapshot omits or changes a producing build dependency') if recipe(root, commit) != data['arm64_recipe']: raise ValueError('Snapshot recipe differs from the producing source') @@ -179,14 +198,19 @@ def restore(root=ROOT): if result.returncode == 0: approved = json.loads(result.stdout) if approved != data: - prove_origin(data, root / 'app/build/component-downloads', root) + try: + prove_origin(data, root / 'app/build/component-downloads', root) + except (subprocess.CalledProcessError, OSError, http.client.HTTPException): + print('Snapshot origin is unavailable; rebuilding without trusting the snapshot.') + matching = dict.fromkeys(INPUTS, False) + if any(matching.values()): url = (f"https://github.com/Droid-Deck/DroidDeck-Components/releases/download/" f"preloads-{data['archive_sha256']}/preloads.tar.gz") try: with urllib.request.urlopen(url, timeout=60) as response: content = response.read(64 * 1024 * 1024) - except urllib.error.HTTPError as error: - if error.code != 404: + except (OSError, http.client.HTTPException) as error: + if isinstance(error, urllib.error.HTTPError) and error.code not in (404, 429) and error.code < 500: raise print('Preload snapshot is unavailable; rebuilding from source.') matching = dict.fromkeys(INPUTS, False) diff --git a/tools/components/prepare.py b/tools/components/prepare.py new file mode 100644 index 000000000..064b452be --- /dev/null +++ b/tools/components/prepare.py @@ -0,0 +1,167 @@ +"""One entry point for runtime recipes, cache identities and verified job handoff.""" +import argparse +import hashlib +import json +import os +from pathlib import Path, PurePosixPath +import shutil +import subprocess +import tarfile + +ROOT = Path(__file__).resolve().parents[2] +REGISTRY = 'tools/components/recipes.json' +TREES = ['app/src/main/assets/linuxfs', 'app/src/main/assets/directaudio', + 'app/src/main/assets/droiddeck-esync', 'app/build/prepared-assets', + 'app/build/component-records'] +LIBRARIES = ['app/src/main/jniLibs/arm64-v8a/' + name for name in + ('libproot.so', 'libproot-loader.so', 'libdirectaudiorelay.so')] +MANIFEST = 'app/build/prepared-components.json' + + +def digest(path): + with path.open('rb') as stream: + return hashlib.file_digest(stream, 'sha256').hexdigest() + + +def registry(root=ROOT): + return json.loads((root / REGISTRY).read_text()) + + +def identity(name, root=ROOT): + spec = registry(root)[name] + paths = subprocess.check_output(['git', 'ls-files', '--cached', '--others', '--exclude-standard', + '--', *spec['inputs'], *spec.get('source_inputs', [])], cwd=root, text=True).splitlines() + values = {} + for name_ in sorted(set(paths)): + if '__pycache__' in name_ or name_.endswith(('.pyc', '.md')): + continue + content = (root / name_).read_bytes() + if b'\0' not in content: + content = content.replace(b'\r\n', b'\n') + values[name_] = hashlib.sha256(content).hexdigest() + policy = dict(spec=spec, sources=values, runner=os.environ.get('RUNNER_OS', 'Linux'), + architecture=os.environ.get('RUNNER_ARCH', 'X64')) + entrypoint = root / 'tools/components/prepare.py' + if entrypoint.is_file(): + policy['entrypoint'] = hashlib.sha256(entrypoint.read_bytes().replace(b'\r\n', b'\n')).hexdigest() + installer = root / 'tools/ci/install-ubuntu-packages.sh' + if spec.get('packages') and installer.is_file(): + policy['installer'] = hashlib.sha256(installer.read_bytes().replace(b'\r\n', b'\n')).hexdigest() + if spec.get('ndk'): + policy['ndk'] = os.environ.get('NDK_VERSION', '') + policy['ndk_policy'] = hashlib.sha256((root / 'tools/ci/toolchains.env').read_bytes().replace(b'\r\n', b'\n')).hexdigest() + return hashlib.sha256(json.dumps(policy, sort_keys=True).encode()).hexdigest() + + +def prepare(name, root=ROOT): + spec = registry(root)[name] + prepare_tools([name], root) + if 'command' in spec: + command = spec['command'] + else: + script = (root / spec['script']).resolve() + if not script.is_relative_to((root / 'tools/components').resolve()): + raise ValueError('Recipe script escapes components directory') + command = ['bash', str(script)] + environment = dict(os.environ) + if spec.get('source'): + environment.update(COMPONENT_SOURCE_URL=spec['source']['url'], + COMPONENT_SOURCE_SHA256=spec['source']['sha256']) + subprocess.run(command, cwd=root, env=environment, check=True) + missing = [name_ for name_ in spec['outputs'] if not (root / name_).is_file()] + if missing: + raise ValueError(f'{name} did not prepare required outputs: {missing}') + record = dict(component=name, input_sha256=identity(name, root), + outputs={p: digest(root / p) for p in spec['outputs']}) + destination = root / 'app/build/component-records' / (name + '.json') + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text(json.dumps(record, sort_keys=True, indent=2) + '\n') + + +def prepare_tools(names, root=ROOT): + packages = sorted({package for name in names for package in registry(root)[name].get('packages', [])}) + if packages: + subprocess.run(['bash', 'tools/ci/install-ubuntu-packages.sh', *packages], cwd=root, check=True) + + +def files(root=ROOT): + result = [] + for name in TREES: + folder = root / name + if not folder.is_dir() or folder.is_symlink(): + raise ValueError('Missing prepared component tree: ' + name) + for path in folder.rglob('*'): + if path.is_symlink(): + raise ValueError('Symlink in prepared components: ' + str(path)) + if path.is_file(): + result.append(path.relative_to(root).as_posix()) + for name in LIBRARIES: + if not (root / name).is_file() or (root / name).is_symlink(): + raise ValueError('Missing prepared component library: ' + name) + result.append(name) + return sorted(result) + + +def pack(destination, root=ROOT): + names = files(root) + tree = subprocess.check_output(['git', 'rev-parse', 'HEAD^{tree}'], cwd=root, text=True).strip() + data = dict(version=1, source_tree=tree, run=os.environ.get('GITHUB_RUN_ID', ''), + recipes={name: identity(name, root) for name in registry(root)}, + files={name: digest(root / name) for name in names}) + (root / MANIFEST).write_text(json.dumps(data, sort_keys=True, indent=2) + '\n') + destination.parent.mkdir(parents=True, exist_ok=True) + with tarfile.open(destination, mode='w:gz', compresslevel=1) as archive: + for name in names + [MANIFEST]: + archive.add(root / name, arcname=name, recursive=False) + + +def unpack(archive_path, source_tree, root=ROOT): + total = 0 + seen = set() + with tarfile.open(archive_path, mode='r:gz') as archive: + for entry in archive: + allowed = entry.name in LIBRARIES + [MANIFEST] or any(entry.name.startswith(p + '/') for p in TREES) + path = root / entry.name + total += entry.size + if (not allowed or '\\' in entry.name or '..' in PurePosixPath(entry.name).parts + or PurePosixPath(entry.name).as_posix() != entry.name + or not entry.isfile() or entry.name in seen or len(seen) >= 100000 + or entry.size > 512 * 1024 * 1024 + or total > 2 * 1024**3 or not path.resolve().is_relative_to(root.resolve()) + or path.is_symlink()): + raise ValueError('Unsafe prepared component archive') + seen.add(entry.name) + path.parent.mkdir(parents=True, exist_ok=True) + with path.open('wb') as output: + shutil.copyfileobj(archive.extractfile(entry), output) + path.chmod(entry.mode & 0o777) + data = json.loads((root / MANIFEST).read_text()) + actual = {name: digest(root / name) for name in files(root)} + recipes = {name: identity(name, root) for name in registry(root)} + if (data.get('version') != 1 or data['source_tree'] != source_tree or data['files'] != actual + or data['recipes'] != recipes): + raise ValueError('Prepared components do not match the pinned source tree and file hashes') + print('Prepared component source identity and all output hashes verified.') + + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('recipe') + parser.add_argument('--archive', type=Path, default=ROOT / 'app/build/prepared-components.tar.gz') + parser.add_argument('--tree') + args = parser.parse_args() + if args.recipe == 'plan': + for name in registry(): + value = identity(name) + print(f'{name}: {value}') + if os.environ.get('GITHUB_OUTPUT'): + with open(os.environ['GITHUB_OUTPUT'], 'a') as stream: + stream.write(f'{name}={value}\n') + elif args.recipe == 'tools': + prepare_tools(os.environ.get('COMPONENTS_TO_BUILD', '').split()) + elif args.recipe == 'pack': + pack(args.archive) + elif args.recipe == 'unpack': + unpack(args.archive, args.tree) + else: + prepare(args.recipe) diff --git a/tools/components/recipes.json b/tools/components/recipes.json new file mode 100644 index 000000000..19144018f --- /dev/null +++ b/tools/components/recipes.json @@ -0,0 +1,225 @@ +{ + "runtime": { + "script": "tools/components/recipes/runtime.sh", + "inputs": [ + "tools/components/recipes/runtime.sh", + "tools/quest", + "tools/linuxfs/overlay", + "tools/linuxfs/desktop", + "tools/linuxfs/licenses", + "tools/linuxfs/uruntime.env", + "tools/gamescope/release.env", + "tools/wlroots/release.env", + "tools/mangoapp", + "tools/msitools", + "tools/pipewire", + "tools/vr", + "app/src/main/cpp/waylandcomp/generated", + "app/src/main/cpp/waylandcomp/src/headpose_shm.h", + "app/src/main/cpp/waylandcomp/src/spatialpose_shm.h", + "app/src/main/cpp/venc/venc_proto.h" + ], + "outputs": [ + "app/src/main/assets/linuxfs/usr/local/bin/gamescope", + "app/src/main/assets/linuxfs/usr/local/lib/droiddeck-vr/droiddeck/bin/linuxarm64/driver_droiddeck.so" + ], + "ndk": true, + "packages": [ + "gcc-aarch64-linux-gnu", + "g++-aarch64-linux-gnu", + "libvulkan1", + "mesa-vulkan-drivers" + ] + }, + "esync": { + "script": "tools/components/recipes/esync.sh", + "inputs": [ + "tools/components/recipes/esync.sh", + "tools/droiddeck-esync/release.env" + ], + "outputs": [ + "app/src/main/assets/droiddeck-esync/index.json", + "app/src/main/assets/droiddeck-esync/index.json.sig" + ] + }, + "debian-image": { + "script": "tools/components/recipes/debian-image.sh", + "inputs": [ + "tools/components/recipes/debian-image.sh" + ], + "outputs": [] + }, + "x86-preloads": { + "script": "tools/components/recipes/x86-preloads.sh", + "inputs": [ + "tools/components/recipes/x86-preloads.sh", + "tools/linuxfs/build-x86-preloads.sh", + "tools/linuxfs/preload", + "tools/linuxfs/fex" + ], + "outputs": [ + "app/src/main/assets/linuxfs/x86_64/libfakeinput.so", + "app/src/main/assets/linuxfs/x86_64/libblsession.so", + "app/src/main/assets/linuxfs/x86_64/libfaultreport.so", + "app/src/main/assets/linuxfs/i386/libfakeinput.so", + "app/src/main/assets/linuxfs/i386/libblsession.so", + "app/src/main/assets/linuxfs/i386/libfaultreport.so", + "app/src/main/assets/linuxfs/x86_64/libwebkitinput.so", + "app/src/main/assets/linuxfs/x86_64/libvulkan-thunk.so" + ], + "source_inputs": [ + "app/src/main/cpp/fakeinput_steam.cpp", + "app/src/main/cpp/adrenotools/src/hook/kgsl.h", + "app/src/main/cpp/venc/venc_proto.h", + "tools/linuxfs/preload", + "tools/quest/kgsl_compat.c", + "tools/quest/kgsl_compat.h", + "tools/linuxfs/fex", + "tools/linuxfs/build-x86-preloads.sh", + "tools/linuxfs/debian-image.env" + ], + "snapshot_outputs": [ + "x86_64/libfakeinput.so", + "x86_64/libblsession.so", + "x86_64/libfaultreport.so", + "i386/libfakeinput.so", + "i386/libblsession.so", + "i386/libfaultreport.so", + "x86_64/libwebkitinput.so", + "x86_64/libvulkan-thunk.so" + ] + }, + "pulseaudio-source": { + "script": "tools/components/recipes/pulseaudio-source.sh", + "inputs": [ + "tools/components/recipes/pulseaudio-source.sh" + ], + "outputs": [ + "pa13/src/pulsecore/module.h" + ], + "source": { + "url": "https://github.com/pulseaudio/pulseaudio/archive/refs/tags/v13.0.tar.gz", + "sha256": "bd7980bc9bd44bd3b3363545dabd19476ebf4d6bb6d2527e7d179256394a6758" + } + }, + "directaudio": { + "script": "tools/components/recipes/directaudio.sh", + "inputs": [ + "tools/components/recipes/directaudio.sh", + "tools/directaudio" + ], + "outputs": [ + "app/src/main/jniLibs/arm64-v8a/libdirectaudiorelay.so" + ] + }, + "audio": { + "script": "tools/components/recipes/audio.sh", + "inputs": [ + "tools/aaudio-sink", + "tools/directaudio", + "tools/components/recipes/audio.sh", + "app/src/main/assets/pulseaudio.tzst" + ], + "outputs": [ + "app/build/prepared-assets/pulseaudio.tzst" + ], + "ndk": true + }, + "proot": { + "script": "tools/components/recipes/proot.sh", + "inputs": [ + "tools/proot", + "tools/components/recipes/proot.sh" + ], + "outputs": [ + "app/src/main/jniLibs/arm64-v8a/libproot.so", + "app/src/main/jniLibs/arm64-v8a/libproot-loader.so" + ], + "ndk": true + }, + "comet": { + "script": "tools/components/recipes/comet.sh", + "inputs": [ + "tools/comet", + "tools/components/recipes/comet.sh" + ], + "outputs": [ + "app/src/main/assets/linuxfs/usr/local/lib/droiddeck-comet/comet", + "app/src/main/assets/linuxfs/usr/local/lib/droiddeck-comet/version" + ], + "packages": [ + "gcc-aarch64-linux-gnu" + ] + }, + "arm64-preloads": { + "script": "tools/components/recipes/arm64-preloads.sh", + "inputs": [ + "tools/components/build-arm64-preloads.sh", + "tools/linuxfs/preload", + "app/src/main/cpp/fakeinput_steam.cpp", + "tools/components/recipes/arm64-preloads.sh" + ], + "outputs": [ + "app/src/main/assets/linuxfs/libfakeinput.so", + "app/src/main/assets/linuxfs/libblsession.so", + "app/src/main/assets/linuxfs/libblfastpath.so", + "app/src/main/assets/linuxfs/libblaudit.so", + "app/src/main/assets/linuxfs/libssbs.so", + "app/src/main/assets/linuxfs/usr/local/bin/droiddeck-clipboard" + ], + "packages": [ + "gcc-aarch64-linux-gnu", + "g++-aarch64-linux-gnu" + ], + "source_inputs": [ + "app/src/main/cpp/fakeinput_steam.cpp", + "app/src/main/cpp/adrenotools/src/hook/kgsl.h", + "app/src/main/cpp/venc/venc_proto.h", + "tools/linuxfs/preload", + "tools/quest/kgsl_compat.c", + "tools/quest/kgsl_compat.h", + "tools/proot/fastpath", + "tools/linuxfs/ssbs", + "tools/linuxfs/clipboard" + ], + "snapshot_outputs": [ + "libfakeinput.so", + "libblsession.so", + "libblfastpath.so", + "libblaudit.so", + "libssbs.so", + "usr/local/bin/droiddeck-clipboard" + ] + }, + "preload-snapshot": { + "command": [ + "python3", + "tools/components/preloads.py", + "restore" + ], + "inputs": [ + "tools/components/preloads.py", + "tools/components/preloads.lock.json" + ], + "outputs": [] + }, + "xbox": { + "script": "tools/components/recipes/xbox.sh", + "inputs": [ + "tools/xbox", + "tools/components/recipes/xbox.sh" + ], + "outputs": [ + "app/src/main/assets/linuxfs/usr/local/lib/droiddeck-xbox/xodus-cli", + "app/src/main/assets/linuxfs/usr/local/lib/droiddeck-xbox/xodus-service" + ], + "packages": [ + "build-essential", + "pkg-config", + "libssl-dev", + "protobuf-compiler", + "cmake", + "xz-utils" + ] + } +} diff --git a/tools/components/recipes/arm64-preloads.sh b/tools/components/recipes/arm64-preloads.sh new file mode 100644 index 000000000..c6a227c2e --- /dev/null +++ b/tools/components/recipes/arm64-preloads.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -euo pipefail +bash tools/components/build-arm64-preloads.sh app/src/main/assets/linuxfs diff --git a/tools/components/recipes/audio.sh b/tools/components/recipes/audio.sh new file mode 100644 index 000000000..819421d7c --- /dev/null +++ b/tools/components/recipes/audio.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail +export NDK="${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}" +tools/aaudio-sink/build.sh "$PWD/pa13" "$PWD/sink-out" +rm -rf pa-bundle && mkdir -p pa-bundle +tar --use-compress-program=unzstd -xf app/src/main/assets/pulseaudio.tzst -C pa-bundle +test ! -f pa-bundle/modules/arm64/module-aaudio-sink.so +install -m755 sink-out/module-aaudio-sink.so pa-bundle/modules/arm64/module-aaudio-sink.so +install -m755 sink-out/module-directaudio-native-sink.so pa-bundle/modules/arm64/module-directaudio-native-sink.so +(cd pa-bundle && tar --use-compress-program='zstd -19' -cf ../pulseaudio.tzst .) +mkdir -p app/build/prepared-assets +mv pulseaudio.tzst app/build/prepared-assets/pulseaudio.tzst +tar --use-compress-program=unzstd -tf app/build/prepared-assets/pulseaudio.tzst | grep -c '\.so$' +rm -rf pa-bundle sink-out diff --git a/tools/components/recipes/comet.sh b/tools/components/recipes/comet.sh new file mode 100644 index 000000000..e47d3e3d5 --- /dev/null +++ b/tools/components/recipes/comet.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail +rustup toolchain install 1.90.0 --profile minimal +rustup target add --toolchain 1.90.0 aarch64-unknown-linux-gnu +RUSTUP_TOOLCHAIN=1.90.0 bash tools/comet/build.sh diff --git a/tools/components/recipes/debian-image.sh b/tools/components/recipes/debian-image.sh new file mode 100644 index 000000000..f982225dc --- /dev/null +++ b/tools/components/recipes/debian-image.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail +. tools/linuxfs/debian-image.env +image=public.ecr.aws/docker/library/debian:bullseye +if gh release download "$DEBIAN_IMAGE_TAG" -R "$GITHUB_REPOSITORY" -p "$DEBIAN_IMAGE_ASSET" -O /tmp/debian-image.tar.zst \ + && { [ -z "$DEBIAN_IMAGE_SHA256" ] || echo "$DEBIAN_IMAGE_SHA256 /tmp/debian-image.tar.zst" | sha256sum -c -; }; then + zstd -dc /tmp/debian-image.tar.zst | docker load + rm -f /tmp/debian-image.tar.zst +else + echo "::warning::the hosted Debian image could not be fetched; pulling from the registry instead" + for attempt in 1 2 3 4 5; do + docker pull "$image" && break + [ "$attempt" -lt 5 ] || { echo "::error::could not pull $image after $attempt attempts"; exit 1; } + echo "pull attempt $attempt failed (registry busy?); retrying in $((attempt * 30)) s" + sleep $((attempt * 30)) + done +fi +docker image ls "$image" diff --git a/tools/components/recipes/directaudio.sh b/tools/components/recipes/directaudio.sh new file mode 100644 index 000000000..c684f0262 --- /dev/null +++ b/tools/components/recipes/directaudio.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -euo pipefail +tools/directaudio/fetch.sh "$PWD" "$PWD/sink-out" \ No newline at end of file diff --git a/tools/components/recipes/esync.sh b/tools/components/recipes/esync.sh new file mode 100644 index 000000000..691f77e99 --- /dev/null +++ b/tools/components/recipes/esync.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail +if [ -f tools/droiddeck-esync/release.env ]; then + . tools/droiddeck-esync/release.env + release_url="https://github.com/${SYNC_BUNDLE_REPO}/releases/download/${SYNC_BUNDLE_TAG}" + curl -fsSL --retry 3 -o sync-bundle.tzst "$release_url/$SYNC_BUNDLE_ASSET" + echo "$SYNC_BUNDLE_SHA256 sync-bundle.tzst" | sha256sum -c - + mkdir -p app/src/main/assets/droiddeck-esync + tar --use-compress-program=unzstd -xf sync-bundle.tzst -C app/src/main/assets/droiddeck-esync + rm sync-bundle.tzst + curl -fsSL --retry 3 -o sync-index.json "$release_url/index.json" + revoked=$(python3 -c 'import json, sys; print(" ".join(p["id"] for p in json.load(open(sys.argv[1]))["packs"] if p.get("revoked") is True))' sync-index.json) + for id in $revoked; do + if [ -e "app/src/main/assets/droiddeck-esync/packs/$id.tzst" ]; then + echo "::warning::$SYNC_BUNDLE_ASSET carries revoked pack $id; it is left out of the APK (point tools/droiddeck-esync/release.env at a newer bundle)" + rm -f "app/src/main/assets/droiddeck-esync/packs/$id.tzst" + fi + done + rm sync-index.json + test -f app/src/main/assets/droiddeck-esync/index.json + test -f app/src/main/assets/droiddeck-esync/index.json.sig + ls -lR app/src/main/assets/droiddeck-esync +fi diff --git a/tools/components/recipes/proot.sh b/tools/components/recipes/proot.sh new file mode 100644 index 000000000..9bf7699e8 --- /dev/null +++ b/tools/components/recipes/proot.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +export NDK="${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}" +tools/proot/build.sh app/src/main/jniLibs/arm64-v8a diff --git a/tools/components/recipes/pulseaudio-source.sh b/tools/components/recipes/pulseaudio-source.sh new file mode 100644 index 000000000..5a2a6ed09 --- /dev/null +++ b/tools/components/recipes/pulseaudio-source.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail +curl -fsSL --retry 3 -o pa.tar.gz "$COMPONENT_SOURCE_URL" +echo "$COMPONENT_SOURCE_SHA256 pa.tar.gz" | sha256sum -c - +mkdir -p pa13 && tar -xzf pa.tar.gz -C pa13 --strip-components=1 && rm pa.tar.gz diff --git a/tools/components/recipes/runtime.sh b/tools/components/recipes/runtime.sh new file mode 100644 index 000000000..0e640ab11 --- /dev/null +++ b/tools/components/recipes/runtime.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +set -euo pipefail +D=app/src/main/assets/linuxfs +mkdir -p "$D" +VK_INCLUDE=$(ls -d "${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}"/toolchains/llvm/prebuilt/*/sysroot/usr/include | head -1) \ + tools/vr/build.sh --native "$D/usr/local/lib/droiddeck-vr" +for script in tools/linuxfs/overlay/usr/local/bin/droiddeck-* tools/linuxfs/overlay/usr/local/bin/steam-compatibility; do + install -Dm644 "$script" "$D/usr/local/bin/$(basename "$script")" +done +for f in tools/linuxfs/overlay/usr/bin/* tools/linuxfs/overlay/usr/bin/steamos-polkit-helpers/*; do + [ -f "$f" ] && install -Dm644 "$f" "$D/${f#tools/linuxfs/overlay/}" +done +if [ -f tools/gamescope/release.env ]; then + . tools/gamescope/release.env + curl -fsSL --retry 3 -o gamescope.tzst "https://github.com/${GAMESCOPE_REPO:-${GITHUB_REPOSITORY}}/releases/download/${GAMESCOPE_TAG}/gamescope.tzst" + echo "${GAMESCOPE_SHA256} gamescope.tzst" | sha256sum -c - + tar --use-compress-program=unzstd -xf gamescope.tzst -C "$D" + test -f "$D/usr/local/bin/gamescope" + rm gamescope.tzst +fi +. tools/linuxfs/uruntime.env +curl -fsSL --retry 3 -o uruntime "https://github.com/VHSgunzo/uruntime/releases/download/${URUNTIME_VERSION}/${URUNTIME_ASSET}" +echo "${URUNTIME_SHA256} uruntime" | sha256sum -c - +install -Dm644 uruntime "$D/usr/local/lib/droiddeck/uruntime" +install -Dm644 tools/linuxfs/licenses/uruntime-LICENSE "$D/usr/local/share/licenses/uruntime/LICENSE" +rm uruntime +M="$D/usr/local/lib/mangoapp" +mkdir -p "$M" mango-pkgs +grep -v '^#' tools/mangoapp/packages.txt | while read -r sum url; do + [ -n "$url" ] || continue + curl -fsSL --retry 3 -o mango-pkgs/pkg.tar.zst "$url" + echo "$sum mango-pkgs/pkg.tar.zst" | sha256sum -c - + tar --use-compress-program=unzstd -xf mango-pkgs/pkg.tar.zst -C mango-pkgs + rm mango-pkgs/pkg.tar.zst +done +install -m644 mango-pkgs/usr/bin/mangoapp "$M/mangoapp" +for so in libfmt.so.10 libspdlog.so.1.13 libglfw.so.3 libtraceevent.so.1; do + cp -L "mango-pkgs/usr/lib/$so" "$M/$so" +done +aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -Wl,-soname,libtracefs.so.1 \ + -o "$M/libtracefs.so.1" tools/mangoapp/libtracefs-shim.c +aarch64-linux-gnu-strip --strip-unneeded "$M/libtracefs.so.1" +install -Dm644 tools/mangoapp/mangoapp "$D/usr/local/bin/mangoapp" +rm -rf mango-pkgs +ls -l "$M" +mkdir -p pw-pkgs +grep -v '^#' tools/pipewire/packages.txt | while read -r sum url; do + [ -n "$url" ] || continue + curl -fsSL --retry 3 -o pw-pkgs/pkg.tar.zst "$url" + echo "$sum pw-pkgs/pkg.tar.zst" | sha256sum -c - + tar --use-compress-program=unzstd -xf pw-pkgs/pkg.tar.zst -C pw-pkgs + rm pw-pkgs/pkg.tar.zst +done +tools/pipewire/assemble.sh pw-pkgs "$D/usr/local/lib/droiddeck-pipewire.tzst" +rm -rf pw-pkgs +ls -l "$D/usr/local/lib/droiddeck-pipewire.tzst" +T="$D/usr/local/lib/droiddeck-msitools" +mkdir -p "$T" msi-pkgs +grep -v '^#' tools/msitools/packages.txt | while read -r sum url; do + [ -n "$url" ] || continue + curl -fsSL --retry 3 -o msi-pkgs/pkg.tar.xz "$url" + echo "$sum msi-pkgs/pkg.tar.xz" | sha256sum -c - + tar -xJf msi-pkgs/pkg.tar.xz -C msi-pkgs + rm msi-pkgs/pkg.tar.xz +done +install -m644 msi-pkgs/usr/bin/msiinfo "$T/msiinfo" +install -m644 msi-pkgs/usr/bin/cabextract "$T/cabextract" +install -m644 msi-pkgs/usr/lib/7zip/7z "$T/7z" +install -m644 msi-pkgs/usr/lib/7zip/7z.so "$T/7z.so" +for so in libmsi-1.0.so.0 libgsf-1.so.114 libgcab-1.0.so.0; do + cp -L "msi-pkgs/usr/lib/$so" "$T/$so" +done +for f in NOTICE GPL-2 GPL-3 LGPL-2.1 7zip-License 7zip-unRarLicense; do + install -Dm644 "tools/msitools/licenses/$f" "$D/usr/local/share/licenses/droiddeck-msitools/$f" +done +rm -rf msi-pkgs +ls -l "$T" +install -Dm644 tools/linuxfs/desktop/droiddeck-desktop "$D/usr/local/bin/droiddeck-desktop" +install -Dm644 tools/linuxfs/desktop/droiddeck-gpu "$D/usr/local/bin/droiddeck-gpu" +install -Dm644 tools/linuxfs/desktop/droiddeck-desktop-gpu "$D/usr/local/bin/droiddeck-desktop-gpu" +install -Dm644 tools/linuxfs/desktop/kwin_wayland_wrapper "$D/usr/local/bin/kwin_wayland_wrapper" +install -Dm644 tools/linuxfs/desktop/firefox-droiddeck.js "$D/usr/lib/firefox/defaults/pref/droiddeck.js" +ls -lR "$D" diff --git a/tools/components/recipes/x86-preloads.sh b/tools/components/recipes/x86-preloads.sh new file mode 100644 index 000000000..c107c543b --- /dev/null +++ b/tools/components/recipes/x86-preloads.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail +docker run --rm -v "$PWD:/src" -w /src public.ecr.aws/docker/library/debian:bullseye bash -c ' + set -euo pipefail + install_compilers() { + apt-get -o Acquire::Check-Valid-Until=false update -qq && + DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends gcc g++ gcc-multilib g++-multilib binutils >/dev/null + } + printf "deb http://deb.debian.org/debian bullseye main\ndeb http://security.debian.org/debian-security bullseye-security main\n" > /etc/apt/sources.list + if ! install_compilers; then + printf "deb http://archive.debian.org/debian bullseye main\ndeb http://archive.debian.org/debian-security bullseye-security main\n" > /etc/apt/sources.list + install_compilers + fi + tools/linuxfs/build-x86-preloads.sh app/src/main/assets/linuxfs +' +sudo chown -R "$(id -u):$(id -g)" app/src/main/assets/linuxfs +ls -l app/src/main/assets/linuxfs/x86_64 app/src/main/assets/linuxfs/i386 diff --git a/tools/components/recipes/xbox.sh b/tools/components/recipes/xbox.sh new file mode 100644 index 000000000..87c953656 --- /dev/null +++ b/tools/components/recipes/xbox.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +rustup toolchain install 1.99.0 --profile minimal +bash tools/xbox/build.sh diff --git a/tools/release/announce-preview.sh b/tools/release/announce-preview.sh new file mode 100644 index 000000000..c4b63e727 --- /dev/null +++ b/tools/release/announce-preview.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail +if [ -z "$WEBHOOK" ]; then + echo "::notice::No DISCORD_BUILDS_WEBHOOK secret; nothing posted" + exit 0 +fi +commit=$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA") +subject=$(jq -r '.commit.message | split("\n")[0]' <<<"$commit") +title=$(jq -r '.commit.message | split("\n") | map(select(length > 0)) | .[1] // ""' <<<"$commit") +pr=$(sed -n 's/^Merge pull request #\([0-9]*\).*/\1/p' <<<"$subject") +if [ -n "$pr" ]; then + what="[#$pr]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/pull/$pr): ${title:-$subject}" +else + what="[\`${GITHUB_SHA::7}\`]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/commit/$GITHUB_SHA): $subject" +fi +get=$(printf 'Download: [standard APK](<%s>) · [all variants](<%s>)' "$APK" "$RELEASE") +content=$(printf '**New signed Preview build** `main-%s`\n%s\nIn the app: **Updates → Preview**\n%s' "${GITHUB_SHA::7}" "$what" "$get") +jq -n --arg content "$content" '{content: $content, allowed_mentions: {parse: []}}' | + curl -fsS -H 'Content-Type: application/json' -d @- "$WEBHOOK" -o /dev/null +echo "Posted: $content" diff --git a/tools/release/describe-build.sh b/tools/release/describe-build.sh new file mode 100644 index 000000000..46bc339ba --- /dev/null +++ b/tools/release/describe-build.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail +pr=$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls" \ + --jq '[.[] | select(.merged_at != null and .base.ref == "main")] | first // empty') +if [ -n "$pr" ]; then + number=$(jq -r .number <<<"$pr") + title=$(jq -r .title <<<"$pr") + merged=$(jq -r '.merged_at[:10]' <<<"$pr") + summary=$(jq -r '.body // ""' <<<"$pr" | tr -d '\r' | { grep -v '^🤖' || true; } | + awk '!p && /^#/ { next } NF { p = 1; print; next } p { exit }' | head -n 6 | cut -c1-400) + { + echo "**Merged to main** - [PR #$number]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/pull/$number): $title (merged $merged)" + if [ -n "$summary" ]; then + echo + echo "$summary" + fi + } > notes-head.md +else + subject=$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA" --jq '.commit.message | split("\n")[0]') + echo "**Pushed to main** without a PR - $subject" > notes-head.md +fi +cat notes-head.md diff --git a/tools/release/publish-ci.sh b/tools/release/publish-ci.sh new file mode 100644 index 000000000..987ef622d --- /dev/null +++ b/tools/release/publish-ci.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -euo pipefail +tag=main-${GITHUB_SHA::7} +cat signed/SHA256SUMS-*.txt > signed/SHA256SUMS.txt +rm signed/SHA256SUMS-*.txt +notes="$(cat notes-head.md) + +Signed build of $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/commit/$GITHUB_SHA - in main, not yet in a DroidDeck release; for testing. +Commit: $GITHUB_SHA +VersionCode: $(sed -n 's/^ *versionCode \([0-9][0-9]*\)$/\1/p' app/build.gradle) +Each apk installs over a DroidDeck release and keeps its data. \`standard\` is the normal app; the others are the same app under another package name, installed beside it. + +SHA-256: +\`\`\` +$(cat signed/SHA256SUMS.txt) +\`\`\`" +gh release delete "$tag" -R "$CI_REPO" --yes --cleanup-tag 2>/dev/null || true +gh release create "$tag" -R "$CI_REPO" --target main --prerelease \ + --title "main ${GITHUB_SHA::7}" --notes "$notes" signed/* +gh release list -R "$CI_REPO" -L 200 --json tagName,publishedAt \ + --jq '[.[] | select(.tagName | startswith("main-"))] | sort_by(.publishedAt) | reverse | .['"$KEEP"':][] | .tagName' | + while IFS= read -r old; do + if [ "$old" = "$tag" ]; then + continue + fi + if ! gh release delete "$old" -R "$CI_REPO" --yes --cleanup-tag 2>/dev/null; then + if gh release view "$old" -R "$CI_REPO" >/dev/null 2>&1; then + echo "::error::Could not delete old release $old" + exit 1 + fi + fi + done +published=$(gh release view "$tag" -R "$CI_REPO" --json tagName,assets) +jq -e --arg tag "$tag" '.tagName == $tag and (.assets | length) > 0' <<< "$published" >/dev/null +url="$GITHUB_SERVER_URL/$CI_REPO/releases/tag/$tag" +apk=$(cd signed && ls DroidDeck-*.apk | awk 'length(a) == 0 || length($0) < length(a) { a = $0 } END { print a }') +echo "release=$url" >> "$GITHUB_OUTPUT" +echo "apk=$GITHUB_SERVER_URL/$CI_REPO/releases/download/$tag/$apk" >> "$GITHUB_OUTPUT" +echo "Published $url" diff --git a/tools/release/report-build.sh b/tools/release/report-build.sh new file mode 100644 index 000000000..fc031c634 --- /dev/null +++ b/tools/release/report-build.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +set -uo pipefail +MARK="" +{ + echo "$MARK" + echo "## Build of this pull request" + echo + [ -f /tmp/pr-merge.md ] && cat /tmp/pr-merge.md + echo + case "$BUILD_OUTCOME:$UNIT_TEST_OUTCOME:$APK_OUTCOME" in + success:success:success) + echo "### Build passed" + echo + echo "The \`droiddeck-apk\` artifact is [on this run]($RUN_URL), signed with the public test key." + echo + if [ "$SAME_REPO" = true ]; then + echo "After the parallel regression tests pass, a release-signed tester build that installs over Stable or Preview will be published and will appear in DroidDeck's **Test builds** list. Label the PR \`no-test-build\` to skip that." + else + echo "**Maintainer:** add the \`publish-test\` label to publish a release-signed tester build that installs over Stable or Preview and appears in DroidDeck's **Test builds** list. The label is removed automatically after publishing." + fi + echo + echo "Fallback: [open **PR Release Publish**]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/workflows/pr-release-publish.yml) on \`main\` and enter PR \`$PR\`." + ;; + success:success:*) + echo "### APK publication incomplete" + echo + echo "Compilation and unit tests passed, but APK publication did not complete. See [the run]($RUN_URL)." + ;; + success:failure:*) + echo "### ERROR: Unit tests failed" + echo + echo "The APK compiled, but unit tests failed. No installable artifact was published." + echo + if [ -n "$TEST_DIAGNOSTICS_URL" ]; then + echo "The [unit-test-results]($TEST_DIAGNOSTICS_URL) artifact contains the test log and any generated reports." + else + echo "The test diagnostics upload did not complete. See [the test log]($RUN_URL)." + fi + ;; + success:*) + echo "### Build incomplete" + echo + echo "The APK compiled, but unit tests did not complete successfully. See [the run]($RUN_URL)." + ;; + failure:*) + echo "### ERROR: Build failed" + echo + echo "The compiler's errors ([full log]($RUN_URL)):" + echo + echo '```' + { grep -E '^e: |^w: .*error|error:|CMake Error|fatal error|ninja: error|FAILED|What went wrong|Execution failed for task|Unresolved reference|Type mismatch|Unresolved' /tmp/build.log 2>/dev/null \ + | sed -e 's#file:///home/runner/work/[^/]*/[^/]*/##' -e 's#/home/runner/work/[^/]*/[^/]*/##' \ + | grep -v '^w: ' | awk '!seen[$0]++' | head -80; } || true + echo '```' + echo + echo "
What went wrong (Gradle)" + echo + echo '```' + awk '/^\* What went wrong/{p=1} /^\* Try:/{p=0} p{print}' /tmp/build.log 2>/dev/null | head -60 + echo '```' + echo "
" + ;; + skipped:*|cancelled:*) + echo "### Not built" + echo + echo "The build did not run (see above, or [the run]($RUN_URL))." + ;; + esac + echo +} > /tmp/pr-report.md +cat /tmp/pr-report.md >> "$GITHUB_STEP_SUMMARY" +ID=$(gh api "repos/${GITHUB_REPOSITORY}/issues/${PR}/comments" --paginate --jq ".[] | select(.body | startswith(\"$MARK\")) | .id" | head -1 || true) +if [ -n "$ID" ]; then + gh api -X PATCH "repos/${GITHUB_REPOSITORY}/issues/comments/${ID}" -F body=@/tmp/pr-report.md >/dev/null +else + gh api -X POST "repos/${GITHUB_REPOSITORY}/issues/${PR}/comments" -F body=@/tmp/pr-report.md >/dev/null +fi diff --git a/tools/release/runtime_inputs.py b/tools/release/runtime_inputs.py index d6cf46bc4..83f87be03 100644 --- a/tools/release/runtime_inputs.py +++ b/tools/release/runtime_inputs.py @@ -23,7 +23,7 @@ PACK_NAMES = ['proot', 'directaudio', 'tracking', 'xbox', 'comet'] SOURCE_PATHS = ['app/build.gradle', 'app/src/main/cpp/fakeinput_steam.cpp', 'app/src/main/assets/pulseaudio.tzst', 'tools/linuxfs', 'tools/proot', 'tools/aaudio-sink', 'tools/directaudio', 'tools/gamescope/release.env', 'tools/droiddeck-esync/release.env', - 'tools/mangoapp', 'tools/msitools', 'tools/pipewire', 'tools/comet', 'tools/xbox', 'tools/components', 'tools/build_local.sh', + 'tools/mangoapp', 'tools/msitools', 'tools/pipewire', 'tools/comet', 'tools/xbox', 'tools/components', 'tools/ci', 'tools/build_local.sh', 'tools/release/runtime_inputs.py', 'app/src/main/assets/desktop-removal', 'tools/quest', 'tools/vr/src', 'tools/vr/driver_droiddeck', 'tools/vr/layer', 'tools/vr/build.sh', 'app/src/main/cpp/waylandcomp/src/headpose_shm.h', diff --git a/tools/tests/test_ci_checks.py b/tools/tests/test_ci_checks.py index df472a47d..3a0216d77 100644 --- a/tools/tests/test_ci_checks.py +++ b/tools/tests/test_ci_checks.py @@ -12,6 +12,12 @@ ROOT = Path(__file__).resolve().parents[2] class CiChecksTest(unittest.TestCase): + def test_independent_jobs_wait_only_for_the_pinned_source(self): + jobs = yaml.safe_load((ROOT / '.github/workflows/build.yml').read_text())['jobs'] + for name in ('xbox', 'components', 'python-tests'): + self.assertEqual(jobs[name]['needs'], 'source') + self.assertEqual(set(jobs['build']['needs']), {'source', 'components', 'xbox'}) + def test_dependency_update_errors_block_installation(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) diff --git a/tools/tests/test_ci_ndk.py b/tools/tests/test_ci_ndk.py new file mode 100644 index 000000000..eef737db6 --- /dev/null +++ b/tools/tests/test_ci_ndk.py @@ -0,0 +1,27 @@ +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[2] + + +class CiNdkTest(unittest.TestCase): + def test_installed_ndk_must_match_the_declared_version(self): + version = (ROOT / 'tools/ci/toolchains.env').read_text().strip().split('=', 1)[1] + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + ndk = root / 'ndk' / version + ndk.mkdir(parents=True) + properties = ndk / 'source.properties' + properties.write_text('Pkg.Revision = ' + version + '\n') + output = root / 'environment' + env = dict(os.environ, ANDROID_HOME=str(root), GITHUB_ENV=str(output)) + script = ROOT / 'tools/ci/resolve-ndk.sh' + subprocess.run(['bash', str(script)], env=env, check=True) + self.assertIn('NDK_VERSION=' + version, output.read_text()) + self.assertIn('ANDROID_NDK_HOME=' + str(ndk), output.read_text()) + properties.write_text('Pkg.Revision = unexpected\n') + self.assertNotEqual(subprocess.run(['bash', str(script)], env=env).returncode, 0) diff --git a/tools/tests/test_component_prepare.py b/tools/tests/test_component_prepare.py new file mode 100644 index 000000000..b513c8fe7 --- /dev/null +++ b/tools/tests/test_component_prepare.py @@ -0,0 +1,106 @@ +import importlib.util +import io +import json +import os +from pathlib import Path +import subprocess +import tarfile +import tempfile +import unittest +from unittest.mock import patch + +SPEC = importlib.util.spec_from_file_location('component_prepare', Path(__file__).resolve().parents[1] / 'components/prepare.py') +components = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(components) + + +class ComponentPrepareTest(unittest.TestCase): + def setUp(self): + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + self.root = Path(directory.name) + self.fixture(self.root) + + def fixture(self, root): + for name in components.TREES: + (root / name).mkdir(parents=True) + (root / 'tools/components/recipes').mkdir(parents=True) + (root / 'input.h').write_text('#define VALUE 1\n') + (root / 'tools/components/recipes/fake.sh').write_text('printf binary > app/src/main/assets/linuxfs/demo.so\n') + spec = dict(script='tools/components/recipes/fake.sh', inputs=['input.h', 'tools/components/recipes/fake.sh'], + outputs=['app/src/main/assets/linuxfs/demo.so']) + (root / components.REGISTRY).write_text(json.dumps(dict(fake=spec))) + for name in components.LIBRARIES: + (root / name).parent.mkdir(parents=True, exist_ok=True) + (root / name).write_bytes(b'library') + subprocess.run(['git', 'init', '-q', str(root)], check=True) + subprocess.run(['git', '-C', str(root), 'config', 'core.autocrlf', 'false'], check=True) + subprocess.run(['git', '-C', str(root), 'add', '.'], check=True) + subprocess.run(['git', '-C', str(root), '-c', 'user.name=Test Developer', '-c', 'user.email=test@example.test', + 'commit', '-qm', 'baseline'], check=True) + + def test_recipe_failure_or_missing_output_does_not_succeed(self): + script = self.root / 'tools/components/recipes/fake.sh' + script.write_text('exit 7\n') + with self.assertRaises(subprocess.CalledProcessError): + components.prepare('fake', self.root) + script.write_text('exit 0\n') + with self.assertRaisesRegex(ValueError, 'required outputs'): + components.prepare('fake', self.root) + + def test_identity_covers_source_and_recipe_changes(self): + original = components.identity('fake', self.root) + (self.root / 'input.h').write_text('#define VALUE 2\n') + changed = components.identity('fake', self.root) + self.assertNotEqual(original, changed) + script = self.root / 'tools/components/recipes/fake.sh' + script.write_text(script.read_text() + '\nprintf changed\n') + self.assertNotEqual(changed, components.identity('fake', self.root)) + + def test_tool_requirements_are_installed_in_one_union(self): + definitions = dict(one=dict(packages=['gcc', 'cmake']), two=dict(packages=['gcc', 'qemu-user'])) + with patch.object(components, 'registry', return_value=definitions), patch.object(components.subprocess, 'run') as run: + components.prepare_tools(['one', 'two'], self.root) + run.assert_called_once_with(['bash', 'tools/ci/install-ubuntu-packages.sh', 'cmake', 'gcc', 'qemu-user'], + cwd=self.root, check=True) + + def test_prepared_handoff_verifies_every_file_and_source_tree(self): + components.prepare('fake', self.root) + archive = self.root / 'prepared.tar.gz' + components.pack(archive, self.root) + tree = subprocess.check_output(['git', 'rev-parse', 'HEAD^{tree}'], cwd=self.root, text=True).strip() + target = self.root / 'target' + self.fixture(target) + components.unpack(archive, tree, target) + self.assertEqual((target / 'app/src/main/assets/linuxfs/demo.so').read_bytes(), b'binary') + with self.assertRaisesRegex(ValueError, 'pinned source'): + components.unpack(archive, 'wrong-tree', target) + + def test_tampered_component_bytes_fail_handoff_verification(self): + components.prepare('fake', self.root) + archive = self.root / 'prepared.tar.gz' + components.pack(archive, self.root) + corrupt = self.root / 'corrupt.tar.gz' + with tarfile.open(archive) as original, tarfile.open(corrupt, 'w:gz') as output: + for entry in original: + content = original.extractfile(entry).read() + if entry.name.endswith('demo.so'): + content = b'tampered' + entry.size = len(content) + output.addfile(entry, io.BytesIO(content)) + target = self.root / 'target' + self.fixture(target) + tree = subprocess.check_output(['git', 'rev-parse', 'HEAD^{tree}'], cwd=self.root, text=True).strip() + with self.assertRaisesRegex(ValueError, 'file hashes'): + components.unpack(corrupt, tree, target) + + def test_archive_cannot_overwrite_source_through_traversal(self): + archive = self.root / 'bad.tar.gz' + with tarfile.open(archive, mode='w:gz') as tar: + name = 'app/src/main/assets/linuxfs/../../../../../input.h' + entry = tarfile.TarInfo(name) + entry.size = 3 + tar.addfile(entry, io.BytesIO(b'bad')) + with self.assertRaisesRegex(ValueError, 'Unsafe'): + components.unpack(archive, 'tree', self.root) + self.assertEqual((self.root / 'input.h').read_text(), '#define VALUE 1\n') diff --git a/tools/tests/test_esync_packs.py b/tools/tests/test_esync_packs.py index 863a1003b..7ba20b448 100644 --- a/tools/tests/test_esync_packs.py +++ b/tools/tests/test_esync_packs.py @@ -1160,8 +1160,12 @@ class BundleTest(unittest.TestCase): except ImportError: self.skipTest("PyYAML is not installed") apk = yaml.safe_load((self.ROOT / ".github/workflows/build.yml").read_text()) - steps = {step.get("name"): step for step in apk["jobs"]["build"]["steps"]} - bundle = steps["Bundle the droiddeck-esync packs"] + steps = {step.get("name"): step for step in apk["jobs"]["components"]["steps"]} + import json + recipes = json.loads((self.ROOT / "tools/components/recipes.json").read_text()) + bundle = dict(steps["Bundle the droiddeck-esync packs"]) + self.assertIn("prepare.py esync", bundle["run"]) + bundle["run"] = (self.ROOT / recipes["esync"]["script"]).read_text() self.assertNotIn("if", bundle) self.assertIn("tools/droiddeck-esync/release.env", bundle["run"]) self.assertIn('release_url="https://github.com/${SYNC_BUNDLE_REPO}/releases/download/${SYNC_BUNDLE_TAG}"', bundle["run"]) @@ -1169,7 +1173,7 @@ class BundleTest(unittest.TestCase): self.assertIn('curl -fsSL --retry 3 -o sync-index.json "$release_url/index.json"', bundle["run"]) self.assertIn("sha256sum -c -", bundle["run"]) self.assertNotIn("revoked.txt", bundle["run"]) - stage = next(step for step in apk["jobs"]["build"]["steps"] if "overlay/usr/local/bin/droiddeck-*" in step.get("run", "")) + stage = {"run": (self.ROOT / recipes["runtime"]["script"]).read_text()} self.assertIn("tools/linuxfs/overlay/usr/local/bin/droiddeck-* ", stage["run"]) local = (self.ROOT / "tools/build_local.sh").read_text() self.assertIn("tools/linuxfs/overlay/usr/local/bin/droiddeck-* ", local) diff --git a/tools/tests/test_preload_snapshots.py b/tools/tests/test_preload_snapshots.py index 5a4cfb606..ee03da0f8 100644 --- a/tools/tests/test_preload_snapshots.py +++ b/tools/tests/test_preload_snapshots.py @@ -100,6 +100,13 @@ class PreloadSnapshotTest(unittest.TestCase): result, _, _ = self.restore() self.assertEqual(result, dict(arm64=False, x86=True)) + def test_shell_newline_changes_invalidate_the_recipe(self): + script = self.root / 'tools/components/build-arm64-preloads.sh' + original = snapshots.recipe(self.root) + text = script.read_text() + script.write_text(text.replace('-ldl\n', '-ldl ', 1)) + self.assertNotEqual(original, snapshots.recipe(self.root)) + def test_new_manifest_requires_main_build_proof(self): result, _, prove = self.restore('HEAD~1') self.assertTrue(all(result.values())) @@ -112,6 +119,18 @@ class PreloadSnapshotTest(unittest.TestCase): with self.assertRaisesRegex(ValueError, 'original main APK'): snapshots.prove_origin(data, self.root / 'cache', self.root) + def test_modified_input_policy_cannot_omit_producer_dependencies(self): + data = copy.deepcopy(self.data) + data['origin']['commit'] = self.base + omitted = snapshots.COMMON[0] + del data['groups']['arm64'][omitted] + with patch.object(snapshots, 'artifact_bytes', return_value=self.binaries), \ + patch.object(snapshots, 'run', return_value=b''), \ + patch.object(snapshots, 'source_files', side_effect=lambda group, *args: data['groups'][group]), \ + patch.object(snapshots, 'producer_sources', return_value={omitted: self.data['groups']['arm64'][omitted]}): + with self.assertRaisesRegex(ValueError, 'producing build dependency'): + snapshots.prove_origin(data, self.root / 'cache', self.root) + def test_non_main_or_unsuccessful_run_cannot_prove_origin(self): build = dict(event='pull_request', head_branch='main', conclusion='success', head_sha='abc', path='.github/workflows/build.yml', repository=dict(full_name=snapshots.REPO)) @@ -139,6 +158,18 @@ class PreloadSnapshotTest(unittest.TestCase): patch.object(snapshots.urllib.request, 'urlopen', side_effect=error): self.assertEqual(snapshots.restore(self.root), dict(arm64=False, x86=False)) + def test_transient_download_failure_rebuilds_but_integrity_errors_fail(self): + errors = [TimeoutError('network'), snapshots.http.client.IncompleteRead(b'partial'), + snapshots.urllib.error.HTTPError('https://example.test', 503, 'unavailable', {}, None)] + for error in errors: + with self.subTest(error=type(error).__name__), patch.dict(os.environ, COMPONENT_TRUSTED_BASE=self.base), \ + patch.object(snapshots.urllib.request, 'urlopen', side_effect=error): + self.assertEqual(snapshots.restore(self.root), dict(arm64=False, x86=False)) + with patch.dict(os.environ, COMPONENT_TRUSTED_BASE=self.base), \ + patch.object(snapshots.urllib.request, 'urlopen', return_value=io.BytesIO(b'corrupt')): + with self.assertRaisesRegex(ValueError, 'checksum'): + snapshots.restore(self.root) + def test_manual_rebuild_does_not_download_snapshot(self): with patch.dict(os.environ, FORCE_PRELOAD_REBUILD='true'), \ patch.object(snapshots.urllib.request, 'urlopen') as fetch: diff --git a/tools/tests/test_xbox_build_source.py b/tools/tests/test_xbox_build_source.py index 15e35ca5e..427cea342 100644 --- a/tools/tests/test_xbox_build_source.py +++ b/tools/tests/test_xbox_build_source.py @@ -31,14 +31,14 @@ class XboxBuildSourceTest(unittest.TestCase): git('switch', '-q', 'main') (root / 'backend').write_text('merged backend') git('commit', '-qam', 'backend changed after branch') - for name in ('xbox', 'apk', 'tests'): + for name in ('source', 'xbox', 'components', 'apk', 'tests', 'dispatch'): git('clone', '-q', str(root), str(root / name)) git('checkout', '-q', head, cwd=root / name) output = root / 'output' env['GITHUB_OUTPUT'] = str(output) - subprocess.run(['bash', '-c', step('xbox', 'Pin and merge the APK source')], cwd=root / 'xbox', env=env, check=True, capture_output=True) + subprocess.run(['bash', '-c', step('source', 'Pin and merge the APK source')], cwd=root / 'source', env=env, check=True, capture_output=True) base = output.read_text().strip().split('=', 1)[1] - tree = git('rev-parse', 'HEAD^{tree}', cwd=root / 'xbox') + tree = git('rev-parse', 'HEAD^{tree}', cwd=root / 'source') (root / 'backend').write_text('newer main, after backend build') git('commit', '-qam', 'main advanced again') env.update(BUILD_BASE_SHA=base, XBOX_SOURCE_TREE=tree) @@ -50,3 +50,15 @@ class XboxBuildSourceTest(unittest.TestCase): subprocess.run(['bash', '-c', step('python-tests', 'Merge the pinned APK base')], cwd=root / 'tests', env=env, check=True, capture_output=True) subprocess.run(['bash', '-c', step('python-tests', 'Verify backend and test source match')], cwd=root / 'tests', env=env, check=True, capture_output=True) self.assertEqual(git('rev-parse', 'HEAD^{tree}', cwd=root / 'tests'), tree) + + env['SOURCE_TREE'] = tree + for job in ('xbox', 'components'): + subprocess.run(['bash', '-c', step(job, 'Merge the pinned APK base')], cwd=root / job, env=env, check=True, capture_output=True) + subprocess.run(['bash', '-c', step(job, 'Verify pinned source tree')], cwd=root / job, env=env, check=True, capture_output=True) + self.assertEqual(git('rev-parse', 'HEAD^{tree}', cwd=root / job), tree) + + trusted = root / 'trusted-output' + env['GITHUB_OUTPUT'] = str(trusted) + subprocess.run(['bash', '-c', step('source', 'Resolve trusted manifest base')], cwd=root / 'dispatch', env=env, check=True, capture_output=True) + self.assertEqual(trusted.read_text().strip(), 'base-sha=' + git('rev-parse', 'main')) + self.assertNotEqual(git('rev-parse', 'HEAD', cwd=root / 'dispatch'), git('rev-parse', 'main')) From d32b1a13fdef00d1260b423c6f3e3548d2e43c19 Mon Sep 17 00:00:00 2001 From: XxJSONDeruloxX Date: Fri, 23 Oct 2026 14:34:44 -0400 Subject: [PATCH 38/59] ci: verify x86 container build recipe --- tools/components/preloads.lock.json | 3 ++- tools/components/preloads.py | 20 +++++++++++++++++++- tools/tests/test_preload_snapshots.py | 12 +++++++++++- 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/tools/components/preloads.lock.json b/tools/components/preloads.lock.json index a92cd6dea..45beb4152 100644 --- a/tools/components/preloads.lock.json +++ b/tools/components/preloads.lock.json @@ -121,5 +121,6 @@ "arm64": "Ubuntu 24.04 GCC cross compiler; exact build commands pinned by arm64_recipe", "x86": "Debian bullseye compiler image and recipe pinned by source inputs" }, - "version": 1 + "version": 1, + "x86_recipe": "36d06d49f7a4bff5ef675c1e03ce5a5b61498df4c36b9d15a938b41564f755cc" } diff --git a/tools/components/preloads.py b/tools/components/preloads.py index 671474040..5f16610c3 100644 --- a/tools/components/preloads.py +++ b/tools/components/preloads.py @@ -91,6 +91,20 @@ def recipe(root=ROOT, commit=None): return sha(text.replace('\r\n', '\n').encode()) +def x86_recipe(root=ROOT, commit=None): + path = 'tools/components/recipes/x86-preloads.sh' + if commit and not run('git', 'ls-tree', commit, path, root=root): + workflow = run('git', 'show', f'{commit}:.github/workflows/build.yml', root=root).decode() + section = workflow[workflow.index(' - name: Build the x86 preloads'):] + text = section.split(' run: |\n', 1)[1].split('\n - name:', 1)[0] + text = textwrap.dedent(text).rstrip() + '\n' + else: + text = (run('git', 'show', f'{commit}:{path}', root=root).decode() if commit + else (root / path).read_text()) + text = text.removeprefix('#!/usr/bin/env bash\n') + return sha(text.replace('\r\n', '\n').encode()) + + def artifact_bytes(origin, cache): build = api(f"actions/runs/{origin['run']}") if (build['event'] != 'push' or build['head_branch'] != 'main' or build['conclusion'] != 'success' @@ -160,6 +174,8 @@ def prove_origin(data, cache, root=ROOT): raise ValueError('Snapshot omits or changes a producing build dependency') if recipe(root, commit) != data['arm64_recipe']: raise ValueError('Snapshot recipe differs from the producing source') + if x86_recipe(root, commit) != data['x86_recipe']: + raise ValueError('Snapshot x86 recipe differs from the producing source') def unpack(content, data): @@ -188,6 +204,7 @@ def restore(root=ROOT): validate_lock(data) matching = {group: source_files(group, root) == data['groups'][group] for group in INPUTS} matching['arm64'] &= recipe(root) == data['arm64_recipe'] + matching['x86'] &= x86_recipe(root) == data.get('x86_recipe') if os.environ.get('FORCE_PRELOAD_REBUILD') == 'true': matching = dict.fromkeys(INPUTS, False) if any(matching.values()): @@ -243,7 +260,8 @@ def snapshot(run_id, destination, root=ROOT): content = artifact_bytes(origin, root / 'app/build/component-downloads') run('git', 'fetch', '--no-tags', 'origin', origin['commit'], root=root) data = dict(version=1, origin=origin, groups={g: source_files(g, root, origin['commit']) for g in INPUTS}, - arm64_recipe=recipe(root, origin['commit']), outputs={name: sha(value) for name, value in content.items()}, + arm64_recipe=recipe(root, origin['commit']), x86_recipe=x86_recipe(root, origin['commit']), + outputs={name: sha(value) for name, value in content.items()}, toolchain=dict(arm64='Ubuntu 24.04 GCC cross compiler; exact build commands pinned by arm64_recipe', x86='Debian bullseye compiler image and recipe pinned by source inputs')) stream = io.BytesIO() diff --git a/tools/tests/test_preload_snapshots.py b/tools/tests/test_preload_snapshots.py index ee03da0f8..464b24e7b 100644 --- a/tools/tests/test_preload_snapshots.py +++ b/tools/tests/test_preload_snapshots.py @@ -41,6 +41,9 @@ class PreloadSnapshotTest(unittest.TestCase): recipe = 'tools/components/build-arm64-preloads.sh' (self.root / recipe).parent.mkdir(parents=True, exist_ok=True) (self.root / recipe).write_text((snapshots.ROOT / recipe).read_text()) + x86 = 'tools/components/recipes/x86-preloads.sh' + (self.root / x86).parent.mkdir(parents=True, exist_ok=True) + (self.root / x86).write_text((snapshots.ROOT / x86).read_text()) (self.root / '.github/workflows').mkdir(parents=True) (self.root / '.github/workflows/build.yml').write_text((snapshots.ROOT / '.github/workflows/build.yml').read_text()) self.git('init', '-q') @@ -52,7 +55,8 @@ class PreloadSnapshotTest(unittest.TestCase): self.content = archive(self.binaries) self.data = dict(version=1, origin=dict(run=123, commit='abc', artifact=456, artifact_sha256='0' * 64), groups={g: snapshots.source_files(g, self.root) for g in snapshots.INPUTS}, - arm64_recipe=snapshots.recipe(self.root), archive_sha256=snapshots.sha(self.content), + arm64_recipe=snapshots.recipe(self.root), x86_recipe=snapshots.x86_recipe(self.root), + archive_sha256=snapshots.sha(self.content), outputs={name: snapshots.sha(value) for name, value in self.binaries.items()}) (self.root / snapshots.LOCK).write_text(json.dumps(self.data)) self.git('add', '.') @@ -107,6 +111,12 @@ class PreloadSnapshotTest(unittest.TestCase): script.write_text(text.replace('-ldl\n', '-ldl ', 1)) self.assertNotEqual(original, snapshots.recipe(self.root)) + def test_x86_container_recipe_changes_rebuild_x86_only(self): + script = self.root / 'tools/components/recipes/x86-preloads.sh' + script.write_text(script.read_text().replace('debian:bullseye', 'debian:bookworm')) + result, _, _ = self.restore() + self.assertEqual(result, dict(arm64=True, x86=False)) + def test_new_manifest_requires_main_build_proof(self): result, _, prove = self.restore('HEAD~1') self.assertTrue(all(result.values())) From 5d1e70cd203046d90cb2ef4c953386551a31206a Mon Sep 17 00:00:00 2001 From: coffeemeist3r Date: Wed, 21 Oct 2026 23:57:09 -0500 Subject: [PATCH 39/59] Keep FEX's TSO on for Unity games A FEX preset that turns TSO off (Performance, Extreme, Extreme-gn, Denuvo) breaks Unity players under FEX: - Megabonk (Unity 2023.2, IL2CPP, native Linux build, "Linux x86 (FEX)") froze on about half of its launches, every thread parked on a futex. With TSO on: 5 of 5 starts, and a played run stayed responsive. - Crab Game (Windows IL2CPP build under Proton arm64) crashed on 1 of 5 starts with an access violation in UnityPlayer.dll on a garbage pointer. With TSO on: 5 of 5 starts. A Unity player (UnityPlayer.dll, UnityPlayer.so or GameAssembly.so beside the executable) now keeps FEX_TSOENABLED=1 over the shared preset, the way .NET games already keep full TSO. The FEX tool applies the environment half of the game-file fixes too, so native Linux builds get it as well. A FEX preset chosen for the game itself still wins. Related: #1386 (its Mono crash happens with TSO already on) --- docs/development/game-environment.md | 6 +++++ .../overlay/usr/local/bin/droiddeck-fex | 20 +++++++++++--- .../overlay/usr/local/bin/droiddeck-game-env | 9 +++++++ tools/tests/test_droiddeck_fex.py | 26 +++++++++++++++++++ tools/tests/test_game_environment.py | 21 +++++++++++++++ 5 files changed, 78 insertions(+), 4 deletions(-) diff --git a/docs/development/game-environment.md b/docs/development/game-environment.md index a6e02b8e2..62530ca48 100644 --- a/docs/development/game-environment.md +++ b/docs/development/game-environment.md @@ -52,6 +52,12 @@ environment entries and is intentionally narrower than **Reset game profile**. Values are literal strings, with no shell expansion; quotes are only needed when the consuming program expects them. +A Unity game under FEX (`UnityPlayer.dll` for Windows builds under Proton, +`UnityPlayer.so` or `GameAssembly.so` for native Linux builds started by +**Linux x86 (FEX)**) keeps FEX's TSO on when the Default profile's FEX preset +turns it off: without TSO such games can deadlock or crash (seen with Megabonk +and Crab Game). A FEX preset chosen for the game itself still applies. + ## Defaults and available suggestions The initial configuration enables Mesa shader caching with diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-fex b/tools/linuxfs/overlay/usr/local/bin/droiddeck-fex index 331dcfecc..0c05d4089 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-fex +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-fex @@ -671,15 +671,27 @@ def launch(command, env, mode, decide=None): return os.execve(fex["bin"], [fex["bin"], program] + command[1:], guest) -def game_environment(env): +def game_environment(env, command): try: game_env = runpy.run_path(os.path.join(BIN_DIR, "droiddeck-game-env")) env = game_env["game_gpu"](env) app = game_env["game_id"](env) or env.get("SteamAppId") or env.get("SteamGameId") if not app: return env - with open(GAME_ENVIRONMENT, encoding="utf-8") as source: - return game_env["apply_config"](env, json.load(source), str(app)) + fixes = {} + game = game_env["Game"](resolve(command[0], env), command[1:]) + for check in game_env["FIXES"]: + found = check(game) + # Environment fixes only: the launch arguments some fixes add were chosen for Windows builds. + if found and found[0]: + fixes.update(found[0]) + say(found[2]) + try: + with open(GAME_ENVIRONMENT, encoding="utf-8") as source: + config = json.load(source) + except FileNotFoundError: + config = {"version": 1} + return game_env["apply_config"](env, config, str(app), fixes) except FileNotFoundError: return env except Exception as error: @@ -810,7 +822,7 @@ def main(argv): return 0 if not command: return 2 - return launch(command, game_environment(dict(os.environ)), os.environ.get("DROIDDECK_FEX", "auto")) + return launch(command, game_environment(dict(os.environ), command), os.environ.get("DROIDDECK_FEX", "auto")) if action == "run": try: mode, decide, command = parse_run(args) diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env b/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env index a38d77d8c..22b5248f3 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env @@ -87,6 +87,15 @@ def dotnet_memory_ordering(game): return DOTNET_FEX, [], ".NET runtime: FEX with full TSO and without multiblock" +@fix +def unity_memory_ordering(game): + """A Unity player under FEX without TSO deadlocks or crashes: Megabonk (Linux, IL2CPP) froze with every + thread parked on a futex on about half of all launches, Crab Game (Windows under Proton, IL2CPP) died with + an access violation in UnityPlayer.dll. Presets that turn TSO off keep it on for Unity games.""" + if {"unityplayer.so", "gameassembly.so", "unityplayer.dll"} & game.files: + return {"FEX_TSOENABLED": "1"}, [], "Unity: FEX with TSO" + + @fix def godot_vulkan(game): if is_godot(game) and not game.has_arg("--rendering-driver"): diff --git a/tools/tests/test_droiddeck_fex.py b/tools/tests/test_droiddeck_fex.py index fed546ddd..fb8f9ad73 100644 --- a/tools/tests/test_droiddeck_fex.py +++ b/tools/tests/test_droiddeck_fex.py @@ -690,6 +690,32 @@ class LaunchTest(FexTestCase): command, env, mode = G["launch"].call_args[0] self.assertEqual((command, mode), (["/game/run.sh", "-a"], "auto")) + def test_steam_games_get_their_settings_and_linux_unity_keeps_tso(self): + config = self.home / ".config/droiddeck/game-environment.json" + unity = self.write(self.tmp / "Megabonk/Megabonk.x86_64", elf(62), 0o755) + self.write(self.tmp / "Megabonk/UnityPlayer.so", elf(62)) + self.write(self.tmp / "Megabonk/GameAssembly.so", elf(62)) + other = self.write(self.tmp / "Other/game.x86_64", elf(62), 0o755) + self.write(config, json.dumps({"version": 1, "shared": {"FEX_TSOENABLED": "0", "FEX_MULTIBLOCK": "1"}, + "games": {"20": {"FEX_TSOENABLED": "0"}}})) + + def started(program, app): + with mock.patch.dict(G, {"launch": mock.Mock(return_value=0), "GAME_ENVIRONMENT": str(config)}), \ + mock.patch.dict(os.environ, {"SteamAppId": app}, clear=True): + _, said = self.quiet(FEX["main"], ["steam", "waitforexitandrun", str(program), "-arg"]) + command, env, _ = G["launch"].call_args[0] + self.assertEqual(command, [str(program), "-arg"]) + return env.get("FEX_TSOENABLED"), env.get("FEX_MULTIBLOCK"), said + + tso, multiblock, said = started(unity, "10") + self.assertEqual((tso, multiblock), ("1", "1")) + self.assertIn("Unity: FEX with TSO", said) + self.assertEqual(started(other, "10")[:2], ("0", "1")) + self.assertEqual(started(unity, "20")[:2], ("0", "1")) + config.unlink() + self.assertEqual(started(unity, "10")[:2], ("1", None)) + self.assertEqual(started(other, "10")[:2], (None, None)) + class AppInfoTest(unittest.TestCase): COMPAT = runpy.run_path(str(BIN / "steam-compatibility")) diff --git a/tools/tests/test_game_environment.py b/tools/tests/test_game_environment.py index c5c308c8c..bbae1b3a7 100644 --- a/tools/tests/test_game_environment.py +++ b/tools/tests/test_game_environment.py @@ -357,6 +357,27 @@ class GameEnvironmentTest(unittest.TestCase): (asylum / name).touch() self.assertEqual(MODULE["engine_fixes"](str(asylum / "BmLauncher.exe"), []), ({}, [], [])) + def test_unity_players_keep_tso_over_the_preset(self): + with tempfile.TemporaryDirectory() as tmp: + def game(*names): + for name in names: + (Path(tmp) / name).parent.mkdir(parents=True, exist_ok=True) + (Path(tmp) / name).touch() + return str(Path(tmp) / names[0]) + + il2cpp = game("Megabonk/Megabonk.x86_64", "Megabonk/UnityPlayer.so", "Megabonk/GameAssembly.so") + mono = game("Mono/Game.x86_64", "Mono/UnityPlayer.so") + windows = game("Crab Game/Crab Game.exe", "Crab Game/UnityPlayer.dll", "Crab Game/GameAssembly.dll") + windows_mono = game("RoR2/Risk of Rain 2.exe", "RoR2/UnityPlayer.dll", "RoR2/MonoBleedingEdge/mono-2.0-bdwgc.dll") + for exe in (il2cpp, mono, windows, windows_mono): + env, extra, notes = MODULE["engine_fixes"](exe, []) + self.assertEqual((env, extra, notes), ({"FEX_TSOENABLED": "1"}, [], ["Unity: FEX with TSO"])) + performance = {"version": 1, "shared": {"FEX_TSOENABLED": "0", "FEX_VECTORTSOENABLED": "0"}} + result = MODULE["apply_config"]({}, performance, "3405340", env) + self.assertEqual((result["FEX_TSOENABLED"], result["FEX_VECTORTSOENABLED"]), ("1", "0")) + performance["games"] = {"3405340": {"FEX_TSOENABLED": "0"}} + self.assertEqual(MODULE["apply_config"]({}, performance, "3405340", env)["FEX_TSOENABLED"], "0") + def test_a_new_fix_only_needs_registering(self): with tempfile.TemporaryDirectory() as tmp: (Path(tmp) / "Game.exe").touch() From 9438aac0db992cf9699e5b7b4b32bbaa7b4b83ee Mon Sep 17 00:00:00 2001 From: maxjivi05 Date: Fri, 23 Oct 2026 14:55:33 -0400 Subject: [PATCH 40/59] fix: droiddeck-ntsync leaves one-shot handlers alone and replays deferred signals exactly once - Handlers installed with SA_RESETHAND are no longer wrapped, so a deferred one-shot signal can't fall through to the default action. - Deferred signals are replayed with every signal blocked, so a signal landing mid-replay can't send an entry twice. - Taking a deferral slot and holding a signal are safe when one signal interrupts another, and a held signal is released only at the nesting level that held it, so a handler's return can't block it again. --- tools/linuxfs/preload/ntsync.c | 76 +++++++++++++++++------------- tools/tests/test_ntsync_preload.py | 37 +++++++++++++++ 2 files changed, 81 insertions(+), 32 deletions(-) diff --git a/tools/linuxfs/preload/ntsync.c b/tools/linuxfs/preload/ntsync.c index 76e50972d..d49bb42b7 100644 --- a/tools/linuxfs/preload/ntsync.c +++ b/tools/linuxfs/preload/ntsync.c @@ -260,14 +260,16 @@ static void ns_unlock(uint32_t *w) { } #define NS_DEFER 4 +#define NS_LEVELS 8 struct ns_cs { int depth; int count; - int nheld; + int level; + unsigned held_levels; int sigs[NS_DEFER]; siginfo_t info[NS_DEFER]; - sigset_t held; + sigset_t held[NS_LEVELS]; }; static __thread struct ns_cs ns_cs; @@ -284,17 +286,23 @@ static int ns_deferrable(int sig) { static int ns_defer(int sig, const siginfo_t *si) { struct ns_cs *cs = &ns_cs; if (sig < SIGRTMIN) - for (int i = 0; i < cs->count; i++) - if (cs->sigs[i] == sig) return 1; - if (cs->count >= NS_DEFER) return 0; - cs->sigs[cs->count] = sig; - if (si) memcpy(&cs->info[cs->count], si, sizeof(*si)); - else memset(&cs->info[cs->count], 0, sizeof(*si)); - cs->count++; + for (int i = 0; i < NS_DEFER; i++) + if (__atomic_load_n(&cs->sigs[i], __ATOMIC_RELAXED) == sig) return 1; + int i = __atomic_fetch_add(&cs->count, 1, __ATOMIC_RELAXED); + if (i >= NS_DEFER) { + __atomic_fetch_sub(&cs->count, 1, __ATOMIC_RELAXED); + return 0; + } + if (si) memcpy(&cs->info[i], si, sizeof(*si)); + else memset(&cs->info[i], 0, sizeof(*si)); + __atomic_signal_fence(__ATOMIC_SEQ_CST); + __atomic_store_n(&cs->sigs[i], sig, __ATOMIC_RELAXED); return 1; } static int ns_park(int sig, const siginfo_t *si, ucontext_t *uc) { + int level = ns_cs.level; + if (level < 0 || level >= NS_LEVELS) return 0; sigset_t one; sigemptyset(&one); sigaddset(&one, sig); @@ -306,9 +314,10 @@ static int ns_park(int sig, const siginfo_t *si, ucontext_t *uc) { errno = saved; if (!ok) return 0; sigaddset(&uc->uc_sigmask, sig); - if (!ns_cs.nheld) sigemptyset(&ns_cs.held); - sigaddset(&ns_cs.held, sig); - ns_cs.nheld = 1; + unsigned long *words = (unsigned long *)&ns_cs.held[level]; + unsigned bits = 8 * sizeof(unsigned long); + __atomic_fetch_or(&words[(unsigned)(sig - 1) / bits], 1ul << ((unsigned)(sig - 1) % bits), __ATOMIC_RELAXED); + __atomic_fetch_or(&ns_cs.held_levels, 1u << level, __ATOMIC_RELAXED); return 1; } @@ -316,21 +325,34 @@ static void ns_trampoline(int sig, siginfo_t *si, void *uc) { if (__atomic_load_n(&ns_cs.depth, __ATOMIC_RELAXED) > 0 && (ns_defer(sig, si) || (uc && ns_park(sig, si, uc)))) return; uintptr_t h = __atomic_load_n(&ns_handlers[sig], __ATOMIC_ACQUIRE); if (!h) return; + ns_cs.level++; + __atomic_signal_fence(__ATOMIC_SEQ_CST); if (__atomic_load_n(&ns_siginfo[sig], __ATOMIC_RELAXED)) ((void (*)(int, siginfo_t *, void *))h)(sig, si, uc); else ((void (*)(int))h)(sig); + __atomic_signal_fence(__ATOMIC_SEQ_CST); + ns_cs.level--; } static void ns_flush(void) { struct ns_cs *cs = &ns_cs; - int n = cs->count, sigs[NS_DEFER]; - siginfo_t info[NS_DEFER]; - memcpy(sigs, cs->sigs, sizeof(sigs)); - memcpy(info, cs->info, sizeof(info)); - cs->count = 0; + sigset_t all, mask; + sigfillset(&all); int saved = errno; + pthread_sigmask(SIG_BLOCK, &all, &mask); + int n = cs->count < NS_DEFER ? cs->count : NS_DEFER; pid_t pid = getpid(), tid = (pid_t)ns_self(); for (int i = 0; i < n; i++) - if (syscall(SYS_rt_tgsigqueueinfo, pid, tid, sigs[i], &info[i]) != 0) syscall(SYS_tgkill, pid, tid, sigs[i]); + if (cs->sigs[i] > 0 && syscall(SYS_rt_tgsigqueueinfo, pid, tid, cs->sigs[i], &cs->info[i]) != 0) syscall(SYS_tgkill, pid, tid, cs->sigs[i]); + memset(cs->sigs, 0, sizeof(cs->sigs)); + cs->count = 0; + int level = cs->level; + if (level >= 0 && level < NS_LEVELS && (cs->held_levels & (1u << level))) { + for (int sig = 1; sig < _NSIG; sig++) + if (sigismember(&cs->held[level], sig) == 1) sigdelset(&mask, sig); + sigemptyset(&cs->held[level]); + cs->held_levels &= ~(1u << level); + } + pthread_sigmask(SIG_SETMASK, &mask, NULL); errno = saved; } @@ -343,19 +365,8 @@ static void ns_restore(void) { __atomic_signal_fence(__ATOMIC_SEQ_CST); if (--ns_cs.depth == 0) { __atomic_signal_fence(__ATOMIC_SEQ_CST); - int nheld = ns_cs.nheld; - sigset_t held; - if (nheld) { - held = ns_cs.held; - ns_cs.nheld = 0; - __atomic_signal_fence(__ATOMIC_SEQ_CST); - } - if (ns_cs.count) ns_flush(); - if (nheld) { - int saved = errno; - pthread_sigmask(SIG_UNBLOCK, &held, NULL); - errno = saved; - } + if (ns_cs.count || (ns_cs.held_levels && ns_cs.level >= 0 && ns_cs.level < NS_LEVELS && (ns_cs.held_levels & (1u << ns_cs.level)))) + ns_flush(); } } @@ -371,7 +382,8 @@ int sigaction(int sig, const struct sigaction *act, struct sigaction *old) { uint8_t prev_info = __atomic_load_n(&ns_siginfo[sig], __ATOMIC_RELAXED); struct sigaction mine; const struct sigaction *use = act; - if (act && act->sa_handler != SIG_DFL && act->sa_handler != SIG_IGN && bl_from_ntdll(__builtin_return_address(0))) { + if (act && act->sa_handler != SIG_DFL && act->sa_handler != SIG_IGN && !(act->sa_flags & SA_RESETHAND) && + bl_from_ntdll(__builtin_return_address(0))) { mine = *act; mine.sa_sigaction = ns_trampoline; mine.sa_flags |= SA_SIGINFO; diff --git a/tools/tests/test_ntsync_preload.py b/tools/tests/test_ntsync_preload.py index 34f3eaea4..a2c50a7d6 100644 --- a/tools/tests/test_ntsync_preload.py +++ b/tools/tests/test_ntsync_preload.py @@ -23,6 +23,15 @@ int install_handler(int sig, void (*fn)(int, siginfo_t *, void *), void **seen) *seen = (void *)old.sa_sigaction; return 0; } + +int install_oneshot(int sig, void (*fn)(int, siginfo_t *, void *)) { + struct sigaction sa; + memset(&sa, 0, sizeof(sa)); + sa.sa_sigaction = fn; + sa.sa_flags = SA_SIGINFO | SA_RESTART | SA_RESETHAND; + sigemptyset(&sa.sa_mask); + return sigaction(sig, &sa, NULL); +} """ PROGRAM = r""" @@ -63,6 +72,7 @@ struct wait_args { uint64_t timeout, objs; uint32_t count, index, flags, owner, #define NT_EVENT_READ _IOR('N', 0x8d, struct event_args) int install_handler(int sig, void (*fn)(int, siginfo_t *, void *), void **seen); +int install_oneshot(int sig, void (*fn)(int, siginfo_t *, void *)); static int dev, fails; #define CHECK(name, cond) do { if (!(cond)) { printf("FAIL %s line %d errno %d\n", name, __LINE__, errno); fails++; } } while (0) @@ -423,6 +433,32 @@ static void bursts(void) { close(busy_obj); } +static volatile int oneshot_count; +static void oneshot(int sig, siginfo_t *si, void *uc) { + (void)sig; (void)si; (void)uc; + __atomic_add_fetch(&oneshot_count, 1, __ATOMIC_RELAXED); +} + +static void oneshots(void) { + busy_obj = event(0, 0); + pthread_t w; + stop_worker = 0; + oneshot_count = 0; + pthread_create(&w, NULL, worker, NULL); + int fired = 0; + for (int i = 0; i < 300; i++) { + if (install_oneshot(SIGWINCH, oneshot) != 0) break; + int before = __atomic_load_n(&oneshot_count, __ATOMIC_RELAXED); + pthread_kill(w, SIGWINCH); + fired++; + for (int j = 0; j < 2000 && __atomic_load_n(&oneshot_count, __ATOMIC_RELAXED) == before; j++) usleep(50); + } + stop_worker = 1; + pthread_join(w, NULL); + CHECK("oneshot_handlers_run", oneshot_count == fired && fired == 300); + close(busy_obj); +} + static int outer_obj, inner_obj, other_dev, other_obj; static volatile int nested_ok, nested_done, outer_done; static void nesting(int sig, siginfo_t *si, void *uc) { @@ -495,6 +531,7 @@ int main(void) { stress(); signals(); bursts(); + oneshots(); nested_waits(); printf("done fails=%d\n", fails); return fails != 0; From 4d868399100fd0a69718257e27c3eb91afeae3bb Mon Sep 17 00:00:00 2001 From: XxJSONDeruloxX Date: Fri, 23 Oct 2026 15:00:11 -0400 Subject: [PATCH 41/59] fix: preserve GPU stats while pinning clocks --- app/src/main/cpp/waylandcomp/CMakeLists.txt | 1 + .../main/cpp/waylandcomp/src/gpu_clock_pin.c | 160 ++++++++++++++++ .../main/cpp/waylandcomp/src/gpu_clock_pin.h | 5 + .../cpp/waylandcomp/src/waylandcomp_jni.c | 11 +- .../droiddeck/launcher/session/GpuClockPin.kt | 1 + tools/tests/test_gpu_clock_pin.c | 172 ++++++++++++++++++ tools/tests/test_gpu_clock_pin.py | 30 +++ 7 files changed, 378 insertions(+), 2 deletions(-) create mode 100644 app/src/main/cpp/waylandcomp/src/gpu_clock_pin.c create mode 100644 app/src/main/cpp/waylandcomp/src/gpu_clock_pin.h create mode 100644 tools/tests/test_gpu_clock_pin.c create mode 100644 tools/tests/test_gpu_clock_pin.py diff --git a/app/src/main/cpp/waylandcomp/CMakeLists.txt b/app/src/main/cpp/waylandcomp/CMakeLists.txt index 33585818b..f8eb31291 100644 --- a/app/src/main/cpp/waylandcomp/CMakeLists.txt +++ b/app/src/main/cpp/waylandcomp/CMakeLists.txt @@ -8,6 +8,7 @@ add_library(droiddeckwayland SHARED src/sc_layer.c src/ahb_swapchain.c src/waylandcomp_jni.c + src/gpu_clock_pin.c src/droiddeck_ext.c src/wl_dmabuf.c src/wl_clipboard.c diff --git a/app/src/main/cpp/waylandcomp/src/gpu_clock_pin.c b/app/src/main/cpp/waylandcomp/src/gpu_clock_pin.c new file mode 100644 index 000000000..5b079da97 --- /dev/null +++ b/app/src/main/cpp/waylandcomp/src/gpu_clock_pin.c @@ -0,0 +1,160 @@ +#include "gpu_clock_pin.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../../adrenotools/src/hook/kgsl.h" + +static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; +static pthread_t worker; +static bool joinable; +static atomic_bool running; + +struct pin { + int fd; + unsigned int context; + unsigned int memory; + unsigned int timestamp; + unsigned long gpuaddr; + void *commands; + size_t size; +}; + +static struct pin pin = { .fd = -1 }; + +static void release_pin(void) { + if (pin.context) { + struct kgsl_drawctxt_destroy destroy = { .drawctxt_id = pin.context }; + ioctl(pin.fd, IOCTL_KGSL_DRAWCTXT_DESTROY, &destroy); + } + if (pin.commands) munmap(pin.commands, pin.size); + if (pin.memory) { + struct kgsl_gpumem_free_id free_memory = { .id = pin.memory }; + ioctl(pin.fd, IOCTL_KGSL_GPUMEM_FREE_ID, &free_memory); + } + if (pin.fd >= 0) close(pin.fd); + pin = (struct pin) { .fd = -1 }; +} + +static bool submit_pin(void) { + struct kgsl_command_object object = { + .gpuaddr = pin.gpuaddr, + .size = 2 * sizeof(uint32_t), + .flags = KGSL_CMDLIST_IB, + .id = pin.memory, + }; + struct kgsl_gpu_command command = { + .flags = KGSL_CMDBATCH_PWR_CONSTRAINT, + .cmdlist = (uintptr_t)&object, + .cmdsize = sizeof(object), + .numcmds = 1, + .context_id = pin.context, + }; + if (ioctl(pin.fd, IOCTL_KGSL_GPU_COMMAND, &command) < 0) return false; + pin.timestamp = command.timestamp; + return true; +} + +static bool prepare_pin(void) { + pin.fd = open("/dev/kgsl-3d0", O_RDWR | O_CLOEXEC); + if (pin.fd < 0) return false; + struct kgsl_drawctxt_create context = { + .flags = KGSL_CONTEXT_NO_GMEM_ALLOC | KGSL_CONTEXT_PREAMBLE | KGSL_CONTEXT_PWR_CONSTRAINT, + }; + if (ioctl(pin.fd, IOCTL_KGSL_DRAWCTXT_CREATE, &context) < 0) return false; + pin.context = context.drawctxt_id; + struct kgsl_device_constraint_pwrlevel level = { .level = KGSL_CONSTRAINT_PWR_MAX }; + struct kgsl_device_constraint constraint = { + .type = KGSL_CONSTRAINT_PWRLEVEL, + .context_id = pin.context, + .data = &level, + .size = sizeof(level), + }; + struct kgsl_device_getproperty property = { + .type = KGSL_PROP_PWR_CONSTRAINT, + .value = &constraint, + .sizebytes = sizeof(constraint), + }; + if (ioctl(pin.fd, IOCTL_KGSL_SETPROPERTY, &property) < 0) return false; + struct kgsl_gpumem_alloc_id memory = { .size = 4096 }; + if (ioctl(pin.fd, IOCTL_KGSL_GPUMEM_ALLOC_ID, &memory) < 0) return false; + pin.memory = memory.id; + pin.gpuaddr = memory.gpuaddr; + pin.size = memory.mmapsize; + void *commands = mmap(NULL, pin.size, PROT_READ | PROT_WRITE, MAP_SHARED, pin.fd, + (off_t)pin.memory << 12); + if (commands == MAP_FAILED) return false; + pin.commands = commands; + // Type-7 CP_NOP, one payload dword; both odd-parity bits are zero. + uint32_t *words = commands; + words[0] = 0x70100001; + words[1] = 0; + struct kgsl_gpumem_sync_cache sync = { .id = pin.memory, .op = KGSL_GPUMEM_CACHE_CLEAN }; + if (ioctl(pin.fd, IOCTL_KGSL_GPUMEM_SYNC_CACHE, &sync) < 0) return false; + return submit_pin(); +} + +static void *hold_pin(void *unused) { + (void)unused; + while (atomic_load(&running)) { + struct kgsl_device_waittimestamp_ctxtid wait = { + .context_id = pin.context, + .timestamp = pin.timestamp, + .timeout = 100, + }; + if (ioctl(pin.fd, IOCTL_KGSL_DEVICE_WAITTIMESTAMP_CTXTID, &wait) < 0) { + if (errno == EINTR || errno == ETIMEDOUT) continue; + break; + } + // Constraints expire after idle_timer (normally 80 ms); renew with a NOP, keeping devfreq alive. + usleep(25000); + if (atomic_load(&running) && !submit_pin()) break; + } + if (atomic_exchange(&running, false)) + __android_log_print(ANDROID_LOG_WARN, "GpuClockPin", "constraint stopped: %s", strerror(errno)); + release_pin(); + return NULL; +} + +bool gpu_clock_pin_set(bool on) { + pthread_mutex_lock(&lock); + if (joinable) { + if (on && atomic_load(&running)) { + pthread_mutex_unlock(&lock); + return true; + } + atomic_store(&running, false); + pthread_join(worker, NULL); + joinable = false; + } + bool ok = true; + if (on) { + ok = prepare_pin(); + if (ok) { + atomic_store(&running, true); + int error = pthread_create(&worker, NULL, hold_pin, NULL); + if (error) { + atomic_store(&running, false); + errno = error; + ok = false; + } else { + joinable = true; + } + } + if (!ok) { + int error = errno; + release_pin(); + errno = error; + } + } + pthread_mutex_unlock(&lock); + return ok; +} diff --git a/app/src/main/cpp/waylandcomp/src/gpu_clock_pin.h b/app/src/main/cpp/waylandcomp/src/gpu_clock_pin.h new file mode 100644 index 000000000..5fe17d102 --- /dev/null +++ b/app/src/main/cpp/waylandcomp/src/gpu_clock_pin.h @@ -0,0 +1,5 @@ +#pragma once + +#include + +bool gpu_clock_pin_set(bool on); diff --git a/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c b/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c index 6a407088b..bc349cdf6 100644 --- a/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c +++ b/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c @@ -16,6 +16,7 @@ #include "effects_chain.h" #include "cardboard.h" #include "droiddeck_color.h" +#include "gpu_clock_pin.h" extern int droiddeck_wayland_run(void); extern void droiddeck_wayland_set_scale_mode(int mode, int alignment); @@ -634,8 +635,14 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeTextInputDelete(JNIE extern void adrenotools_set_turbo(bool turbo); JNIEXPORT void JNICALL Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetGpuTurbo(JNIEnv *env, jclass clazz, jboolean on) { - (void)env; (void)clazz; - adrenotools_set_turbo(on == JNI_TRUE); + (void)clazz; + if (!gpu_clock_pin_set(on == JNI_TRUE)) { + int saved_errno = errno; + jclass error = (*env)->FindClass(env, "java/lang/IllegalStateException"); + if (error) (*env)->ThrowNew(env, error, strerror(saved_errno)); + } + // Clear a legacy global pin when recovering its marker after an app upgrade or crash. + if (on != JNI_TRUE) adrenotools_set_turbo(false); } JNIEXPORT jint JNICALL diff --git a/app/src/main/java/com/droiddeck/launcher/session/GpuClockPin.kt b/app/src/main/java/com/droiddeck/launcher/session/GpuClockPin.kt index 7855c4959..40135f08d 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/GpuClockPin.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/GpuClockPin.kt @@ -26,6 +26,7 @@ object GpuClockPin { pinned = true "gpu clock: held at its top" } catch (t: Throwable) { + File(context.filesDir, MARKER).delete() Log.w(TAG, "pin", t) "gpu clock: pin failed" } diff --git a/tools/tests/test_gpu_clock_pin.c b/tools/tests/test_gpu_clock_pin.c new file mode 100644 index 000000000..8e3f0b6a4 --- /dev/null +++ b/tools/tests/test_gpu_clock_pin.c @@ -0,0 +1,172 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../../app/src/main/cpp/adrenotools/src/hook/kgsl.h" +#include "../../app/src/main/cpp/waylandcomp/src/gpu_clock_pin.h" + +enum { NONE, OPEN, CONTEXT, CONSTRAINT, MEMORY, MAP, SYNC, SUBMIT, THREAD, WAIT }; +static atomic_int failure, descriptors, contexts, memories, mappings, submissions; +static atomic_bool timeout_wait; +static uint32_t commands[1024]; + +int __android_log_print(int priority, const char *tag, const char *format, ...) { + (void)priority; (void)tag; (void)format; + return 0; +} + +int __wrap_open(const char *path, int flags, ...) { + (void)flags; + assert(!strcmp(path, "/dev/kgsl-3d0")); + if (atomic_load(&failure) == OPEN) { errno = EACCES; return -1; } + assert(atomic_fetch_add(&descriptors, 1) == 0); + return 91; +} + +int __wrap_close(int fd) { + assert(fd == 91); + assert(atomic_fetch_sub(&descriptors, 1) == 1); + errno = EBADF; + return 0; +} + +void *__wrap_mmap(void *address, size_t size, int protection, int flags, int fd, off_t offset) { + (void)address; (void)protection; (void)flags; + assert(fd == 91 && size == sizeof(commands) && offset == (23 << 12)); + if (atomic_load(&failure) == MAP) { errno = EACCES; return MAP_FAILED; } + assert(atomic_fetch_add(&mappings, 1) == 0); + return commands; +} + +int __wrap_munmap(void *address, size_t size) { + assert(address == commands && size == sizeof(commands)); + assert(atomic_fetch_sub(&mappings, 1) == 1); + return 0; +} + +#ifdef __ANDROID__ +int __wrap_ioctl(int fd, int request, ...) { +#else +int __wrap_ioctl(int fd, unsigned long request, ...) { +#endif + assert(fd == 91); + va_list args; + va_start(args, request); + void *arg = va_arg(args, void *); + va_end(args); + int step = NONE; + switch ((unsigned int)request) { + case IOCTL_KGSL_DRAWCTXT_CREATE: step = CONTEXT; break; + case IOCTL_KGSL_SETPROPERTY: step = CONSTRAINT; break; + case IOCTL_KGSL_GPUMEM_ALLOC_ID: step = MEMORY; break; + case IOCTL_KGSL_GPUMEM_SYNC_CACHE: step = SYNC; break; + case IOCTL_KGSL_GPU_COMMAND: step = SUBMIT; break; + case IOCTL_KGSL_DEVICE_WAITTIMESTAMP_CTXTID: step = WAIT; break; + } + if (step && atomic_load(&failure) == step) { errno = EACCES; return -1; } + switch ((unsigned int)request) { + case IOCTL_KGSL_DRAWCTXT_CREATE: { + struct kgsl_drawctxt_create *context = arg; + assert(context->flags & KGSL_CONTEXT_PWR_CONSTRAINT); + context->drawctxt_id = 17; + atomic_fetch_add(&contexts, 1); + break; + } + case IOCTL_KGSL_SETPROPERTY: { + struct kgsl_device_getproperty *property = arg; + assert(property->type == KGSL_PROP_PWR_CONSTRAINT); + struct kgsl_device_constraint *constraint = property->value; + assert(constraint->context_id == 17 && constraint->type == KGSL_CONSTRAINT_PWRLEVEL); + assert(((struct kgsl_device_constraint_pwrlevel *)constraint->data)->level == KGSL_CONSTRAINT_PWR_MAX); + break; + } + case IOCTL_KGSL_GPUMEM_ALLOC_ID: { + struct kgsl_gpumem_alloc_id *memory = arg; + memory->id = 23; memory->gpuaddr = 0x100000; memory->mmapsize = sizeof(commands); + atomic_fetch_add(&memories, 1); + break; + } + case IOCTL_KGSL_GPUMEM_SYNC_CACHE: + assert(((struct kgsl_gpumem_sync_cache *)arg)->op == KGSL_GPUMEM_CACHE_CLEAN); + break; + case IOCTL_KGSL_GPU_COMMAND: { + struct kgsl_gpu_command *command = arg; + struct kgsl_command_object *object = (void *)(uintptr_t)command->cmdlist; + assert(command->context_id == 17 && command->numcmds == 1); + assert(command->flags & KGSL_CMDBATCH_PWR_CONSTRAINT); + assert(object->id == 23 && object->gpuaddr == 0x100000 && object->size == 8); + assert(commands[0] == 0x70100001 && commands[1] == 0); + command->timestamp = atomic_fetch_add(&submissions, 1) + 1; + break; + } + case IOCTL_KGSL_DEVICE_WAITTIMESTAMP_CTXTID: + if (atomic_load(&timeout_wait)) { errno = ETIMEDOUT; return -1; } + break; + case IOCTL_KGSL_DRAWCTXT_DESTROY: + assert(atomic_fetch_sub(&contexts, 1) == 1); + break; + case IOCTL_KGSL_GPUMEM_FREE_ID: + assert(atomic_fetch_sub(&memories, 1) == 1); + break; + default: assert(!"unexpected ioctl"); + } + return 0; +} + +int __real_pthread_create(pthread_t *, const pthread_attr_t *, void *(*)(void *), void *); +int __wrap_pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start)(void *), void *arg) { + if (atomic_load(&failure) == THREAD) return EAGAIN; + return __real_pthread_create(thread, attr, start, arg); +} + +static void assert_released(void) { + assert(!atomic_load(&descriptors) && !atomic_load(&contexts)); + assert(!atomic_load(&memories) && !atomic_load(&mappings)); +} + +int main(void) { + assert(gpu_clock_pin_set(false)); + assert_released(); + for (int step = OPEN; step <= THREAD; ++step) { + atomic_store(&failure, step); + assert(!gpu_clock_pin_set(true)); + assert(errno == (step == THREAD ? EAGAIN : EACCES)); + assert_released(); + } + atomic_store(&failure, NONE); + atomic_store(&submissions, 0); + assert(gpu_clock_pin_set(true)); + assert(gpu_clock_pin_set(true)); + usleep(80000); + assert(atomic_load(&submissions) > 1); + assert(gpu_clock_pin_set(false)); + assert(gpu_clock_pin_set(false)); + assert_released(); + + atomic_store(&timeout_wait, true); + atomic_store(&submissions, 0); + assert(gpu_clock_pin_set(true)); + usleep(80000); + assert(atomic_load(&submissions) == 1); + assert(gpu_clock_pin_set(false)); + assert_released(); + atomic_store(&timeout_wait, false); + + atomic_store(&failure, WAIT); + assert(gpu_clock_pin_set(true)); + for (int i = 0; i < 100 && atomic_load(&descriptors); ++i) usleep(10000); + assert_released(); + atomic_store(&failure, NONE); + assert(gpu_clock_pin_set(true)); + assert(gpu_clock_pin_set(false)); + assert_released(); + puts("gpu clock pin: failure cleanup, renewal, bounded queue and restart passed"); + return 0; +} diff --git a/tools/tests/test_gpu_clock_pin.py b/tools/tests/test_gpu_clock_pin.py new file mode 100644 index 000000000..f6f466871 --- /dev/null +++ b/tools/tests/test_gpu_clock_pin.py @@ -0,0 +1,30 @@ +import pathlib +import shutil +import subprocess +import sys +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] + + +class GpuClockPinTest(unittest.TestCase): + @unittest.skipUnless(sys.platform.startswith('linux') and shutil.which('cc'), 'requires a Linux C compiler') + def test_constraint_lifecycle(self): + with tempfile.TemporaryDirectory() as tmp: + directory = pathlib.Path(tmp) + (directory / 'android').mkdir() + (directory / 'android/log.h').write_text( + '#define ANDROID_LOG_WARN 5\n' + 'int __android_log_print(int, const char *, const char *, ...);\n') + binary = directory / 'gpu-clock-pin-test' + subprocess.run([ + 'cc', '-std=c11', '-D_DEFAULT_SOURCE', '-D__user=', '-Wall', '-Wextra', '-Werror', + '-I', str(directory), '-pthread', + str(ROOT / 'tools/tests/test_gpu_clock_pin.c'), + str(ROOT / 'app/src/main/cpp/waylandcomp/src/gpu_clock_pin.c'), + '-Wl,--wrap=open,--wrap=close,--wrap=mmap,--wrap=munmap,--wrap=ioctl,--wrap=pthread_create', + '-o', str(binary), + ], check=True) + subprocess.run([str(binary)], check=True, timeout=10) From 556bd96d5a77ecea65d7e6f13298c0b8c2d548f0 Mon Sep 17 00:00:00 2001 From: XxJSONDeruloxX Date: Fri, 23 Oct 2026 15:01:04 -0400 Subject: [PATCH 42/59] ci: reuse build metadata and verified pack downloads --- .github/workflows/build.yml | 9 +++ app/build.gradle | 4 ++ tools/packs/build.sh | 9 +-- tools/packs/fetch-verified.sh | 29 ++++++++ tools/release/report-build.sh | 2 +- tools/release/runtime_inputs.py | 2 +- tools/tests/test_ci_checks.py | 25 +++++++ tools/tests/test_pack_downloads.py | 106 +++++++++++++++++++++++++++++ 8 files changed, 176 insertions(+), 10 deletions(-) create mode 100644 tools/packs/fetch-verified.sh create mode 100644 tools/tests/test_pack_downloads.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 265b1faee..b1a585e3e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -484,6 +484,12 @@ jobs: name: xbox-arm64 path: app/src/main/assets/linuxfs/usr/local/lib/droiddeck-xbox + - name: Cache checksum-pinned pack inputs + uses: actions/cache@v4 + with: + path: ~/.cache/droiddeck-pack-inputs + key: pack-inputs-v1-${{ hashFiles('tools/packs/build.sh', 'tools/packs/fetch-verified.sh', 'tools/packs/tracking/release.env') }} + restore-keys: pack-inputs-v1- - name: Build the add-on packs run: bash tools/packs/build.sh app/build/packs @@ -526,6 +532,9 @@ jobs: - name: Unit tests id: unit-tests + env: + DROIDDECK_BUILD_LABEL: ${{ steps.build-label.outputs.label }} + SGDB_API_KEY: ${{ secrets.SGDB_API_KEY }} run: | set -o pipefail mkdir -p app/build diff --git a/app/build.gradle b/app/build.gradle index e81b92711..bdc9ed4d3 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -220,6 +220,10 @@ android { unitTests.returnDefaultValues = true unitTests.includeAndroidResources = true unitTests.all { + if (ciBuild) { + outputs.upToDateWhen { false } + outputs.doNotCacheIf('CI executes the test suite on every run') { true } + } // Robolectric keeps a sandbox per SDK level the tests ask for; seven no longer fit in Gradle's default 512 MB. maxHeapSize = '2g' def logTarget = providers.systemProperty('robolectric.logging').orNull diff --git a/tools/packs/build.sh b/tools/packs/build.sh index d06a4c8fb..27611ab0b 100755 --- a/tools/packs/build.sh +++ b/tools/packs/build.sh @@ -25,14 +25,7 @@ trap 'rm -rf "$work"' EXIT sha256() { if command -v sha256sum >/dev/null; then sha256sum "$1" | cut -d' ' -f1; else shasum -a 256 "$1" | cut -d' ' -f1; fi; } fetch() { - local url=$1 expected=$2 dest=$3 - curl -fsSL --retry 4 -o "$dest" "$url" - local actual - actual=$(sha256 "$dest") - if [ "$actual" != "$expected" ]; then - echo "checksum mismatch for $url: $actual" >&2 - exit 1 - fi + bash "$here/fetch-verified.sh" "$@" } pack() { diff --git a/tools/packs/fetch-verified.sh b/tools/packs/fetch-verified.sh new file mode 100644 index 000000000..0b8c03977 --- /dev/null +++ b/tools/packs/fetch-verified.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail +url=${1:?URL required} +expected=${2:?SHA-256 required} +destination=${3:?destination required} +[[ "$expected" =~ ^[a-f0-9]{64}$ ]] || { echo 'Invalid download SHA-256' >&2; exit 1; } +cache=${DROIDDECK_PACK_CACHE:-"$HOME/.cache/droiddeck-pack-inputs"} +mkdir -p "$cache" "$(dirname "$destination")" +kept="$cache/$expected" +verify() { + if command -v sha256sum >/dev/null; then + echo "$expected $1" | sha256sum -c - >/dev/null 2>&1 + else + echo "$expected $1" | shasum -a 256 -c - >/dev/null 2>&1 + fi +} +if [ -f "$kept" ] && verify "$kept"; then + echo "Verified pack input cache hit: $expected" +else + part=$(mktemp "$cache/$expected.part.XXXXXX") + trap 'rm -f "$part"' EXIT + curl -fsSL --retry 4 --retry-all-errors --connect-timeout 15 --max-time 180 --retry-max-time 240 \ + -o "$part" "$url" + verify "$part" || { echo "Checksum mismatch for $url" >&2; exit 1; } + mv -f "$part" "$kept" + echo "Downloaded and verified pack input: $expected" +fi +cp "$kept" "$destination" +verify "$destination" || { echo 'Copied pack input checksum mismatch' >&2; exit 1; } diff --git a/tools/release/report-build.sh b/tools/release/report-build.sh index fc031c634..75b16f666 100644 --- a/tools/release/report-build.sh +++ b/tools/release/report-build.sh @@ -69,7 +69,7 @@ MARK="" echo } > /tmp/pr-report.md cat /tmp/pr-report.md >> "$GITHUB_STEP_SUMMARY" -ID=$(gh api "repos/${GITHUB_REPOSITORY}/issues/${PR}/comments" --paginate --jq ".[] | select(.body | startswith(\"$MARK\")) | .id" | head -1 || true) +ID=$(gh api "repos/${GITHUB_REPOSITORY}/issues/${PR}/comments" --paginate --jq ".[] | select(.user.login == \"github-actions[bot]\" and (.body | startswith(\"$MARK\"))) | .id" | head -1 || true) if [ -n "$ID" ]; then gh api -X PATCH "repos/${GITHUB_REPOSITORY}/issues/comments/${ID}" -F body=@/tmp/pr-report.md >/dev/null else diff --git a/tools/release/runtime_inputs.py b/tools/release/runtime_inputs.py index 83f87be03..ccce1f0f0 100644 --- a/tools/release/runtime_inputs.py +++ b/tools/release/runtime_inputs.py @@ -21,7 +21,7 @@ PACKED_FILES = {'lib/arm64-v8a/libproot.so': ('proot', 'libproot.so'), PACKED_LIBRARIES = ['lib/arm64-v8a/libopencv_java4.so', 'lib/arm64-v8a/libmediapipe_tasks_jni.so'] PACK_NAMES = ['proot', 'directaudio', 'tracking', 'xbox', 'comet'] SOURCE_PATHS = ['app/build.gradle', 'app/src/main/cpp/fakeinput_steam.cpp', 'app/src/main/assets/pulseaudio.tzst', - 'tools/linuxfs', 'tools/proot', 'tools/aaudio-sink', 'tools/directaudio', + 'tools/linuxfs', 'tools/proot', 'tools/aaudio-sink', 'tools/directaudio', 'tools/packs', 'tools/gamescope/release.env', 'tools/droiddeck-esync/release.env', 'tools/mangoapp', 'tools/msitools', 'tools/pipewire', 'tools/comet', 'tools/xbox', 'tools/components', 'tools/ci', 'tools/build_local.sh', 'tools/release/runtime_inputs.py', 'app/src/main/assets/desktop-removal', diff --git a/tools/tests/test_ci_checks.py b/tools/tests/test_ci_checks.py index 3a0216d77..503b9fb5b 100644 --- a/tools/tests/test_ci_checks.py +++ b/tools/tests/test_ci_checks.py @@ -1,6 +1,9 @@ import itertools +import json import os from pathlib import Path +import shlex +import shutil import subprocess import tempfile import unittest @@ -12,6 +15,28 @@ ROOT = Path(__file__).resolve().parents[2] class CiChecksTest(unittest.TestCase): + def test_report_comment_lookup_ignores_human_marker(self): + if not shutil.which('jq'): + self.skipTest('jq required to execute comment selection') + script = (ROOT / 'tools/release/report-build.sh').read_text() + line = next(line for line in script.splitlines() if line.startswith('ID=')) + arguments = shlex.split(line[len('ID=$('):].split(' | head', 1)[0]) + query = arguments[arguments.index('--jq') + 1].replace('$MARK', '') + comments = [dict(id=1, user=dict(login='someone'), body=' forged'), + dict(id=2, user=dict(login='github-actions[bot]'), body=' result')] + result = subprocess.check_output(['jq', '-r', query], input=json.dumps(comments), text=True) + self.assertEqual(result.strip(), '2') + + def test_build_and_tests_use_identical_generated_metadata(self): + jobs = yaml.safe_load((ROOT / '.github/workflows/build.yml').read_text())['jobs'] + steps = {step.get('id'): step for step in jobs['build']['steps']} + self.assertEqual(steps['build']['env'], steps['unit-tests']['env']) + self.assertIn('DROIDDECK_BUILD_LABEL', steps['unit-tests']['env']) + self.assertIn('SGDB_API_KEY', steps['unit-tests']['env']) + gradle = (ROOT / 'app/build.gradle').read_text() + self.assertIn("outputs.doNotCacheIf('CI executes the test suite on every run') { true }", gradle) + self.assertIn('outputs.upToDateWhen { false }', gradle) + def test_independent_jobs_wait_only_for_the_pinned_source(self): jobs = yaml.safe_load((ROOT / '.github/workflows/build.yml').read_text())['jobs'] for name in ('xbox', 'components', 'python-tests'): diff --git a/tools/tests/test_pack_downloads.py b/tools/tests/test_pack_downloads.py new file mode 100644 index 000000000..20547c03b --- /dev/null +++ b/tools/tests/test_pack_downloads.py @@ -0,0 +1,106 @@ +import hashlib +import http.server +import json +import os +from pathlib import Path +import shutil +import socket +import subprocess +import tempfile +import threading +import unittest + + +ROOT = Path(__file__).resolve().parents[2] +FETCH = ROOT / 'tools/packs/fetch-verified.sh' + + +class PackDownloadsTest(unittest.TestCase): + def setUp(self): + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + self.root = Path(directory.name) + self.cache = self.root / 'cache' + self.cache.mkdir() + self.destination = self.root / 'download' + self.remote = self.root / 'remote' + self.remote.write_bytes(b'verified input') + self.checksum = hashlib.sha256(self.remote.read_bytes()).hexdigest() + self.log = self.root / 'curl.log' + tool = self.root / 'bin/curl' + tool.parent.mkdir() + tool.write_text('#!/usr/bin/env python3\nimport json,os,shutil,sys\n' + 'args=sys.argv[1:]\n' + 'with open(os.environ["CURL_LOG"],"a") as f: f.write(json.dumps(args)+"\\n")\n' + 'shutil.copyfile(os.environ["REMOTE"],args[args.index("-o")+1])\n') + tool.chmod(0o755) + self.env = dict(os.environ, DROIDDECK_PACK_CACHE=str(self.cache), CURL_LOG=str(self.log), + REMOTE=str(self.remote), PATH=str(tool.parent) + os.pathsep + os.environ['PATH']) + + def fetch(self, checksum=None, url='https://example.test/pinned', env=None): + return subprocess.run(['bash', str(FETCH), url, checksum or self.checksum, str(self.destination)], + env=env or self.env, capture_output=True, text=True) + + def test_cold_download_then_warm_reuse_and_corruption_repair(self): + self.assertEqual(self.fetch().returncode, 0) + self.assertEqual(self.fetch().returncode, 0) + self.assertEqual(len(self.log.read_text().splitlines()), 1) + self.assertEqual(self.destination.read_bytes(), b'verified input') + (self.cache / self.checksum).write_bytes(b'corrupt') + self.assertEqual(self.fetch().returncode, 0) + self.assertEqual(len(self.log.read_text().splitlines()), 2) + self.assertEqual((self.cache / self.checksum).read_bytes(), b'verified input') + + def test_changed_pin_cannot_reuse_old_bytes(self): + self.assertEqual(self.fetch().returncode, 0) + self.remote.write_bytes(b'new approved input') + updated = hashlib.sha256(self.remote.read_bytes()).hexdigest() + self.assertEqual(self.fetch(updated).returncode, 0) + self.assertEqual(len(self.log.read_text().splitlines()), 2) + self.assertEqual(self.destination.read_bytes(), b'new approved input') + + def test_bad_download_is_not_cached_or_written(self): + self.remote.write_bytes(b'tampered download') + self.assertNotEqual(self.fetch().returncode, 0) + self.assertFalse((self.cache / self.checksum).exists()) + self.assertFalse(self.destination.exists()) + self.assertEqual(list(self.cache.iterdir()), []) + arguments = json.loads(self.log.read_text()) + self.assertIn('--retry-all-errors', arguments) + self.assertIn('--max-time', arguments) + self.assertIn('--retry-max-time', arguments) + + def test_connection_reset_retries_then_verifies_real_download(self): + if not shutil.which('curl'): + self.skipTest('curl required for transport test') + content = self.remote.read_bytes() + attempts = [] + + class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + attempts.append(True) + if len(attempts) == 1: + self.connection.shutdown(socket.SHUT_RDWR) + self.connection.close() + return + self.send_response(200) + self.send_header('Content-Length', str(len(content))) + self.end_headers() + self.wfile.write(content) + + def log_message(self, *args): + pass + + server = http.server.ThreadingHTTPServer(('127.0.0.1', 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + env = dict(self.env, PATH=os.environ['PATH']) + result = self.fetch(url=f'http://127.0.0.1:{server.server_port}/input', env=env) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertGreaterEqual(len(attempts), 2) + self.assertEqual(self.destination.read_bytes(), content) + finally: + server.shutdown() + server.server_close() + thread.join() From 9c66a1ac0a3f95eef6da438bf62271ee4ec7fc8a Mon Sep 17 00:00:00 2001 From: XxJSONDeruloxX Date: Fri, 23 Oct 2026 15:04:50 -0400 Subject: [PATCH 43/59] fix: select NOP packets for older Adreno cores --- .../main/cpp/waylandcomp/src/gpu_clock_pin.c | 14 ++++++++++++-- tools/tests/test_gpu_clock_pin.c | 17 +++++++++++++++-- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/app/src/main/cpp/waylandcomp/src/gpu_clock_pin.c b/app/src/main/cpp/waylandcomp/src/gpu_clock_pin.c index 5b079da97..0f8e1f61b 100644 --- a/app/src/main/cpp/waylandcomp/src/gpu_clock_pin.c +++ b/app/src/main/cpp/waylandcomp/src/gpu_clock_pin.c @@ -24,6 +24,7 @@ struct pin { unsigned int memory; unsigned int timestamp; unsigned long gpuaddr; + uint32_t nop; void *commands; size_t size; }; @@ -66,6 +67,15 @@ static bool submit_pin(void) { static bool prepare_pin(void) { pin.fd = open("/dev/kgsl-3d0", O_RDWR | O_CLOEXEC); if (pin.fd < 0) return false; + struct kgsl_devinfo info = {0}; + struct kgsl_device_getproperty device_info = { + .type = KGSL_PROP_DEVICE_INFO, .value = &info, .sizebytes = sizeof(info), + }; + if (ioctl(pin.fd, IOCTL_KGSL_DEVICE_GETPROPERTY, &device_info) < 0) return false; + unsigned int core = info.chip_id >> 24; + if (!core) core = info.gpu_id / 100; + if (core < 3) { errno = ENODEV; return false; } + pin.nop = core >= 5 ? 0x70100001 : 0xc0001000; struct kgsl_drawctxt_create context = { .flags = KGSL_CONTEXT_NO_GMEM_ALLOC | KGSL_CONTEXT_PREAMBLE | KGSL_CONTEXT_PWR_CONSTRAINT, }; @@ -93,9 +103,9 @@ static bool prepare_pin(void) { (off_t)pin.memory << 12); if (commands == MAP_FAILED) return false; pin.commands = commands; - // Type-7 CP_NOP, one payload dword; both odd-parity bits are zero. + // A3xx/A4xx use type-3 CP_NOP; newer cores use type-7 with zero odd-parity bits. uint32_t *words = commands; - words[0] = 0x70100001; + words[0] = pin.nop; words[1] = 0; struct kgsl_gpumem_sync_cache sync = { .id = pin.memory, .op = KGSL_GPUMEM_CACHE_CLEAN }; if (ioctl(pin.fd, IOCTL_KGSL_GPUMEM_SYNC_CACHE, &sync) < 0) return false; diff --git a/tools/tests/test_gpu_clock_pin.c b/tools/tests/test_gpu_clock_pin.c index 8e3f0b6a4..b20a6e6fc 100644 --- a/tools/tests/test_gpu_clock_pin.c +++ b/tools/tests/test_gpu_clock_pin.c @@ -12,9 +12,10 @@ #include "../../app/src/main/cpp/adrenotools/src/hook/kgsl.h" #include "../../app/src/main/cpp/waylandcomp/src/gpu_clock_pin.h" -enum { NONE, OPEN, CONTEXT, CONSTRAINT, MEMORY, MAP, SYNC, SUBMIT, THREAD, WAIT }; +enum { NONE, OPEN, DEVICE, CONTEXT, CONSTRAINT, MEMORY, MAP, SYNC, SUBMIT, THREAD, WAIT }; static atomic_int failure, descriptors, contexts, memories, mappings, submissions; static atomic_bool timeout_wait; +static bool legacy_core; static uint32_t commands[1024]; int __android_log_print(int priority, const char *tag, const char *format, ...) { @@ -63,6 +64,7 @@ int __wrap_ioctl(int fd, unsigned long request, ...) { va_end(args); int step = NONE; switch ((unsigned int)request) { + case IOCTL_KGSL_DEVICE_GETPROPERTY: step = DEVICE; break; case IOCTL_KGSL_DRAWCTXT_CREATE: step = CONTEXT; break; case IOCTL_KGSL_SETPROPERTY: step = CONSTRAINT; break; case IOCTL_KGSL_GPUMEM_ALLOC_ID: step = MEMORY; break; @@ -72,6 +74,12 @@ int __wrap_ioctl(int fd, unsigned long request, ...) { } if (step && atomic_load(&failure) == step) { errno = EACCES; return -1; } switch ((unsigned int)request) { + case IOCTL_KGSL_DEVICE_GETPROPERTY: { + struct kgsl_device_getproperty *property = arg; + assert(property->type == KGSL_PROP_DEVICE_INFO); + ((struct kgsl_devinfo *)property->value)->chip_id = legacy_core ? 0x04030000 : 0x07030000; + break; + } case IOCTL_KGSL_DRAWCTXT_CREATE: { struct kgsl_drawctxt_create *context = arg; assert(context->flags & KGSL_CONTEXT_PWR_CONSTRAINT); @@ -102,7 +110,7 @@ int __wrap_ioctl(int fd, unsigned long request, ...) { assert(command->context_id == 17 && command->numcmds == 1); assert(command->flags & KGSL_CMDBATCH_PWR_CONSTRAINT); assert(object->id == 23 && object->gpuaddr == 0x100000 && object->size == 8); - assert(commands[0] == 0x70100001 && commands[1] == 0); + assert(commands[0] == (legacy_core ? 0xc0001000 : 0x70100001) && commands[1] == 0); command->timestamp = atomic_fetch_add(&submissions, 1) + 1; break; } @@ -134,6 +142,11 @@ static void assert_released(void) { int main(void) { assert(gpu_clock_pin_set(false)); assert_released(); + legacy_core = true; + assert(gpu_clock_pin_set(true)); + assert(gpu_clock_pin_set(false)); + assert_released(); + legacy_core = false; for (int step = OPEN; step <= THREAD; ++step) { atomic_store(&failure, step); assert(!gpu_clock_pin_set(true)); From 6cc289f7d3f74eaa3c187e2ea0edaed6b7e2da3d Mon Sep 17 00:00:00 2001 From: XxJSONDeruloxX Date: Fri, 23 Oct 2026 15:06:27 -0400 Subject: [PATCH 44/59] test: avoid GPU pin scheduling races --- tools/tests/test_gpu_clock_pin.c | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tools/tests/test_gpu_clock_pin.c b/tools/tests/test_gpu_clock_pin.c index b20a6e6fc..5fdbe6033 100644 --- a/tools/tests/test_gpu_clock_pin.c +++ b/tools/tests/test_gpu_clock_pin.c @@ -13,7 +13,7 @@ #include "../../app/src/main/cpp/waylandcomp/src/gpu_clock_pin.h" enum { NONE, OPEN, DEVICE, CONTEXT, CONSTRAINT, MEMORY, MAP, SYNC, SUBMIT, THREAD, WAIT }; -static atomic_int failure, descriptors, contexts, memories, mappings, submissions; +static atomic_int failure, descriptors, contexts, memories, mappings, submissions, waits; static atomic_bool timeout_wait; static bool legacy_core; static uint32_t commands[1024]; @@ -115,6 +115,7 @@ int __wrap_ioctl(int fd, unsigned long request, ...) { break; } case IOCTL_KGSL_DEVICE_WAITTIMESTAMP_CTXTID: + atomic_fetch_add(&waits, 1); if (atomic_load(&timeout_wait)) { errno = ETIMEDOUT; return -1; } break; case IOCTL_KGSL_DRAWCTXT_DESTROY: @@ -157,7 +158,7 @@ int main(void) { atomic_store(&submissions, 0); assert(gpu_clock_pin_set(true)); assert(gpu_clock_pin_set(true)); - usleep(80000); + for (int i = 0; i < 100 && atomic_load(&submissions) < 2; ++i) usleep(10000); assert(atomic_load(&submissions) > 1); assert(gpu_clock_pin_set(false)); assert(gpu_clock_pin_set(false)); @@ -165,8 +166,10 @@ int main(void) { atomic_store(&timeout_wait, true); atomic_store(&submissions, 0); + atomic_store(&waits, 0); assert(gpu_clock_pin_set(true)); - usleep(80000); + for (int i = 0; i < 100 && atomic_load(&waits) < 2; ++i) usleep(10000); + assert(atomic_load(&waits) >= 2); assert(atomic_load(&submissions) == 1); assert(gpu_clock_pin_set(false)); assert_released(); From 5a806b35f15f0d36a44680dc8ad7a29f7426b3f4 Mon Sep 17 00:00:00 2001 From: XxJSONDeruloxX Date: Fri, 23 Oct 2026 15:22:01 -0400 Subject: [PATCH 45/59] test: support configured artwork key --- .../droiddeck/launcher/frontend/AddedGameArtLookupTest.kt | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/src/test/java/com/droiddeck/launcher/frontend/AddedGameArtLookupTest.kt b/app/src/test/java/com/droiddeck/launcher/frontend/AddedGameArtLookupTest.kt index 1f1e30c81..f48146201 100644 --- a/app/src/test/java/com/droiddeck/launcher/frontend/AddedGameArtLookupTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/frontend/AddedGameArtLookupTest.kt @@ -1,5 +1,6 @@ package com.droiddeck.launcher.frontend +import com.droiddeck.launcher.BuildConfig import com.droiddeck.launcher.stores.CredentialCipher import org.json.JSONArray import org.json.JSONObject @@ -92,8 +93,9 @@ class AddedGameArtLookupTest { assertEquals("build", SteamGridDb.pick("", "build")) assertEquals("build", SteamGridDb.pick(" ", "build")) assertEquals("", SteamGridDb.pick("", "")) - assertEquals("", SteamGridDb.builtInKey()) - assertEquals("", SteamGridDb.key(app)) + val builtIn = SteamGridDb.builtInKey() + assertTrue(builtIn == BuildConfig.SGDB_API_KEY.trim()) + assertTrue(SteamGridDb.key(app) == builtIn) } @Test fun theUsersKeyIsSealedOnDiskAndClears() { @@ -106,7 +108,7 @@ class AddedGameArtLookupTest { assertEquals("abcdef0123456789secretkey", SteamGridDb.key(app)) SteamGridDb.setUserKey(app, "") assertFalse(file.exists()) - assertEquals("", SteamGridDb.key(app)) + assertTrue(SteamGridDb.key(app) == SteamGridDb.builtInKey()) } @Test fun aFailedLookupIsNotRememberedAsNoMatch() { From a7d6279ee3ce0b857b1a0b9b688f4a1f77830781 Mon Sep 17 00:00:00 2001 From: XxJSONDeruloxX Date: Fri, 23 Oct 2026 15:22:12 -0400 Subject: [PATCH 46/59] test: check artwork key normalization --- .../droiddeck/launcher/frontend/AddedGameArtLookupTest.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/test/java/com/droiddeck/launcher/frontend/AddedGameArtLookupTest.kt b/app/src/test/java/com/droiddeck/launcher/frontend/AddedGameArtLookupTest.kt index f48146201..095cf9a5c 100644 --- a/app/src/test/java/com/droiddeck/launcher/frontend/AddedGameArtLookupTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/frontend/AddedGameArtLookupTest.kt @@ -94,8 +94,8 @@ class AddedGameArtLookupTest { assertEquals("build", SteamGridDb.pick(" ", "build")) assertEquals("", SteamGridDb.pick("", "")) val builtIn = SteamGridDb.builtInKey() - assertTrue(builtIn == BuildConfig.SGDB_API_KEY.trim()) - assertTrue(SteamGridDb.key(app) == builtIn) + assertTrue(builtIn == BuildConfig.SGDB_API_KEY) + assertTrue(SteamGridDb.key(app) == builtIn.trim()) } @Test fun theUsersKeyIsSealedOnDiskAndClears() { @@ -108,7 +108,7 @@ class AddedGameArtLookupTest { assertEquals("abcdef0123456789secretkey", SteamGridDb.key(app)) SteamGridDb.setUserKey(app, "") assertFalse(file.exists()) - assertTrue(SteamGridDb.key(app) == SteamGridDb.builtInKey()) + assertTrue(SteamGridDb.key(app) == SteamGridDb.builtInKey().trim()) } @Test fun aFailedLookupIsNotRememberedAsNoMatch() { From 9835c4e3e16b7a9695c13362bfa370f665645508 Mon Sep 17 00:00:00 2001 From: XxJSONDeruloxX Date: Fri, 23 Oct 2026 15:39:22 -0400 Subject: [PATCH 47/59] ci: persist PR caches and stabilize build time --- .github/workflows/build.yml | 1 + app/build.gradle | 2 +- tools/tests/test_ci_checks.py | 9 +++++++++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b1a585e3e..146e7fe3b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -403,6 +403,7 @@ jobs: - uses: gradle/actions/setup-gradle@v4 with: + cache-read-only: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository }} cache-cleanup: on-success - name: Resolve the pinned Android NDK diff --git a/app/build.gradle b/app/build.gradle index bdc9ed4d3..1b1eb5ae7 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -80,7 +80,7 @@ def sourceCommit = providers.environmentVariable('DROIDDECK_SOURCE_COMMIT').getO def prNumber = (providers.environmentVariable('DROIDDECK_PR_NUMBER').getOrNull() ?: '0').with { it.isInteger() ? it : '0' } def ciBuild = providers.environmentVariable('GITHUB_ACTIONS').getOrNull() == 'true' def releaseSigner = rootProject.file('keystore/release-signer.sha256').text.trim() -def commitTime = (gitValue(['show', '-s', '--format=%ct', 'HEAD']) ?: '0').with { it.isLong() ? it : '0' } +def commitTime = (gitValue(['show', '-s', '--format=%ct', '--end-of-options', sourceCommit]) ?: '0').with { it.isLong() ? it : '0' } 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()] } diff --git a/tools/tests/test_ci_checks.py b/tools/tests/test_ci_checks.py index 503b9fb5b..b7c29fceb 100644 --- a/tools/tests/test_ci_checks.py +++ b/tools/tests/test_ci_checks.py @@ -15,6 +15,15 @@ ROOT = Path(__file__).resolve().parents[2] class CiChecksTest(unittest.TestCase): + def test_pr_cache_writes_and_source_timestamp_policy(self): + jobs = yaml.safe_load((ROOT / '.github/workflows/build.yml').read_text())['jobs'] + cache = next(step for step in jobs['build']['steps'] + if step.get('uses') == 'gradle/actions/setup-gradle@v4')['with'] + self.assertEqual(cache['cache-read-only'], + "${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository }}") + gradle = (ROOT / 'app/build.gradle').read_text() + self.assertIn("gitValue(['show', '-s', '--format=%ct', '--end-of-options', sourceCommit])", gradle) + def test_report_comment_lookup_ignores_human_marker(self): if not shutil.which('jq'): self.skipTest('jq required to execute comment selection') From f909c7a69cc351de7063c048819191bf5b654359 Mon Sep 17 00:00:00 2001 From: maxjivi05 Date: Fri, 23 Oct 2026 15:44:35 -0400 Subject: [PATCH 48/59] fix: x86 Linux games under FEX keep LD_PRELOAD libraries given by name droiddeck-fex dropped every LD_PRELOAD entry that was not an existing path, so a bare soname the loader finds through LD_LIBRARY_PATH (MelonLoader's bootstrap in Drova, for one) never reached the game. Names without a slash are now kept; missing paths and arm64 libraries are still dropped. --- tools/linuxfs/overlay/usr/local/bin/droiddeck-fex | 3 ++- tools/tests/test_droiddeck_fex.py | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-fex b/tools/linuxfs/overlay/usr/local/bin/droiddeck-fex index 331dcfecc..545ea0d1a 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-fex +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-fex @@ -589,7 +589,8 @@ def guest_environment(fex, env): result["FEX_THUNKHOSTLIBS"] = fex["host_thunks"] result["FEX_THUNKGUESTLIBS"] = fex["guest_thunks"] result["FEX_THUNKCONFIG"] = os.path.join(CONFIG_DIR, "ThunksConfig.json") - preload = [part for part in split_paths(result.get("LD_PRELOAD")) if os.path.exists(part) and not is_arm(part)] + preload = [part for part in split_paths(result.get("LD_PRELOAD")) + if "/" not in part or (os.path.exists(part) and not is_arm(part))] library_path = [part for part in (result.get("LD_LIBRARY_PATH") or "").split(":") if part and "arm64" not in part and "aarch64" not in part] for name, parts in (("LD_PRELOAD", preload), ("LD_LIBRARY_PATH", library_path)): diff --git a/tools/tests/test_droiddeck_fex.py b/tools/tests/test_droiddeck_fex.py index fed546ddd..1ba2982da 100644 --- a/tools/tests/test_droiddeck_fex.py +++ b/tools/tests/test_droiddeck_fex.py @@ -549,11 +549,12 @@ class LaunchTest(FexTestCase): def test_the_guest_environment(self): arm = self.write(self.tmp / "arm.so", elf(183)) x86 = self.write(self.tmp / "x86.so", elf(62)) - env = {"LD_PRELOAD": "%s:%s %s" % (arm, x86, self.tmp / "gone.so"), + env = {"LD_PRELOAD": "%s:%s %s libMelonLoader.Bootstrap.so" % (arm, x86, self.tmp / "gone.so"), "LD_LIBRARY_PATH": "/steam/steamrtarm64:/usr/lib/aarch64-linux-gnu:/game/lib:/steam/linuxarm64", "FEX_ROOTFS": "/usr/share/guestos/fex-mesa", "FEX_TSOENABLED": "1"} guest = FEX["guest_environment"]({"portable": True}, env) - self.assertEqual((guest["LD_PRELOAD"], guest["LD_LIBRARY_PATH"], guest["FEX_PORTABLE"]), (str(x86), "/game/lib", "1")) + self.assertEqual((guest["LD_PRELOAD"], guest["LD_LIBRARY_PATH"], guest["FEX_PORTABLE"]), + ("%s:libMelonLoader.Bootstrap.so" % x86, "/game/lib", "1")) self.assertNotIn("FEX_ROOTFS", guest) self.assertEqual(guest["FEX_TSOENABLED"], "1") self.assertTrue(guest["FEX_APP_CONFIG_LOCATION"].endswith("/config/")) From 6378c3f3d2e9e3450563853364146249ff0fdc3b Mon Sep 17 00:00:00 2001 From: maxjivi05 Date: Fri, 23 Oct 2026 15:48:03 -0400 Subject: [PATCH 49/59] fix: the app follows automatic landscape rotation again unless an orientation was picked --- app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt | 2 +- .../main/java/com/droiddeck/launcher/session/SessionPrefs.kt | 4 ++-- app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt | 2 +- .../java/com/droiddeck/launcher/session/OrientationTest.kt | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt b/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt index 52aed25cb..d648f44e7 100644 --- a/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt @@ -163,7 +163,7 @@ class FrontEndActivity : ComponentActivity() { private var directAudio by mutableStateOf(false) private var picturePosition by mutableStateOf("center") private var stretch16x9 by mutableStateOf(false) - private var orientation by mutableStateOf(android.content.pm.ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE) + private var orientation by mutableStateOf(android.content.pm.ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE) private var launcherFullscreen by mutableStateOf(true) private var downloadSource by mutableStateOf(DownloadSource.MODE_AUTO) private var downloadMirror by mutableStateOf("") diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt index 93df36900..aaf084c32 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -50,9 +50,9 @@ object SessionPrefs { ) fun orientation(context: Context): Int = - prefs(context).getInt("orientation", ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE) + prefs(context).getInt("orientation", ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE) .takeIf { value -> orientationOptions.any { it.first == value } } - ?: ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + ?: ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE fun setOrientation(context: Context, orientation: Int) { require(orientationOptions.any { it.first == orientation }) 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 1840d0f07..6e44aca35 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt @@ -132,7 +132,7 @@ class FrontEndState( val controller: com.droiddeck.launcher.input.ControllerPrefs.Settings? = null, val phantomProcessStatus: PhantomProcessStatus = PhantomProcessStatus.NOT_APPLICABLE, val showPhantomGate: Boolean = false, - val orientation: Int = android.content.pm.ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE, + val orientation: Int = android.content.pm.ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE, val launcherFullscreen: Boolean = true, val downloadSource: String = DownloadSource.MODE_AUTO, val downloadMirror: String = "", diff --git a/app/src/test/java/com/droiddeck/launcher/session/OrientationTest.kt b/app/src/test/java/com/droiddeck/launcher/session/OrientationTest.kt index 5ce8030d5..5d67d041a 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/OrientationTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/OrientationTest.kt @@ -20,7 +20,7 @@ class OrientationTest { } @Test fun landscapeChoicesPersistGloballyAndCanRestoreAutomaticRotation() { - assertEquals(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE, SessionPrefs.orientation(context)) + assertEquals(ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE, SessionPrefs.orientation(context)) for (value in listOf( ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE, ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE, @@ -34,7 +34,7 @@ class OrientationTest { @Test fun invalidStoredOrientationFallsBackAndInvalidChoicePreservesTheSavedValue() { val prefs = context.getSharedPreferences("session", Context.MODE_PRIVATE) prefs.edit().putInt("orientation", ActivityInfo.SCREEN_ORIENTATION_PORTRAIT).commit() - assertEquals(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE, SessionPrefs.orientation(context)) + assertEquals(ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE, SessionPrefs.orientation(context)) SessionPrefs.setOrientation(context, ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE) assertThrows(IllegalArgumentException::class.java) { SessionPrefs.setOrientation(context, ActivityInfo.SCREEN_ORIENTATION_PORTRAIT) From b6ca673d6edf5528c788e1cebda766d3ec2dc8ab Mon Sep 17 00:00:00 2001 From: maxjivi05 Date: Fri, 23 Oct 2026 15:50:16 -0400 Subject: [PATCH 50/59] fix: Steam starts on ROMs that refuse the Android ID read --- .../runtime/LinuxNetworkLinkComponent.kt | 17 ++++++++------- .../launcher/runtime/SeededMacTest.kt | 21 +++++++++++++++++++ 2 files changed, 31 insertions(+), 7 deletions(-) create mode 100644 app/src/test/java/com/droiddeck/launcher/runtime/SeededMacTest.kt diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxNetworkLinkComponent.kt b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxNetworkLinkComponent.kt index ac87bd0aa..45f07471b 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxNetworkLinkComponent.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxNetworkLinkComponent.kt @@ -301,15 +301,18 @@ class LinuxNetworkLinkComponent( } } - private fun seededMac(): String { - val id = Settings.Secure.getString(appContext.contentResolver, Settings.Secure.ANDROID_ID) ?: "bannerlator" - val bytes = java.security.MessageDigest.getInstance("SHA-1").digest(id.toByteArray()) - val b0 = (bytes[0].toInt() and 0xfc) or 0x02 - return listOf(b0, bytes[1].toInt(), bytes[2].toInt(), bytes[3].toInt(), bytes[4].toInt(), bytes[5].toInt()) - .joinToString(":") { "%02x".format(it and 0xff) } - } + private fun seededMac(): String = + seededMac { Settings.Secure.getString(appContext.contentResolver, Settings.Secure.ANDROID_ID) } companion object { + internal fun seededMac(readId: () -> String?): String { + val id = runCatching(readId).getOrNull() ?: "bannerlator" + val bytes = java.security.MessageDigest.getInstance("SHA-1").digest(id.toByteArray()) + val b0 = (bytes[0].toInt() and 0xfc) or 0x02 + return listOf(b0, bytes[1].toInt(), bytes[2].toInt(), bytes[3].toInt(), bytes[4].toInt(), bytes[5].toInt()) + .joinToString(":") { "%02x".format(it and 0xff) } + } + private const val TAG = "LinuxNetworkLink" private const val LINK_FILE = "etc/droiddeck-net" private const val LINK_INDEX = 2 diff --git a/app/src/test/java/com/droiddeck/launcher/runtime/SeededMacTest.kt b/app/src/test/java/com/droiddeck/launcher/runtime/SeededMacTest.kt new file mode 100644 index 000000000..e7a521263 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/runtime/SeededMacTest.kt @@ -0,0 +1,21 @@ +package com.droiddeck.launcher.runtime + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test + +class SeededMacTest { + @Test + fun theAndroidIdSeedsAStableLocalMac() { + val mac = LinuxNetworkLinkComponent.seededMac { "0123456789abcdef" } + assertEquals(mac, LinuxNetworkLinkComponent.seededMac { "0123456789abcdef" }) + assertNotEquals(mac, LinuxNetworkLinkComponent.seededMac { null }) + assertEquals(0x02, mac.substringBefore(':').toInt(16) and 0x03) + } + + @Test + fun aRomThatThrowsOnTheAndroidIdReadFallsBackToTheDefaultSeed() { + val mac = LinuxNetworkLinkComponent.seededMac { throw IllegalArgumentException("Illegal char: - at offset 8") } + assertEquals(LinuxNetworkLinkComponent.seededMac { null }, mac) + } +} From 3e85e4a61410044b35c113339023ad81c8867774 Mon Sep 17 00:00:00 2001 From: maxjivi05 Date: Fri, 23 Oct 2026 15:51:06 -0400 Subject: [PATCH 51/59] chore: drop comments --- .../overlay/usr/local/bin/droiddeck-steam-compat | 1 - .../overlay/usr/local/bin/steam-compatibility | 15 --------------- 2 files changed, 16 deletions(-) diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-compat b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-compat index 2793365bc..1de4bcf1c 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-compat +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-compat @@ -617,7 +617,6 @@ class Helper: current[app] = "" elif not current.get(app): current.pop(app, None) - # The client's pick of a tool the app chose for a game wins once the app's choice was applied. requests = COMPAT["settled_game_requests"](state, current, catalog) overrides = COMPAT["requested_games"](requests or {}, catalog, [t["name"] for t in live]) overrides = {app: tool for app, tool in overrides.items() if app not in refused} diff --git a/tools/linuxfs/overlay/usr/local/bin/steam-compatibility b/tools/linuxfs/overlay/usr/local/bin/steam-compatibility index 69030a6b0..29fe4f8e4 100755 --- a/tools/linuxfs/overlay/usr/local/bin/steam-compatibility +++ b/tools/linuxfs/overlay/usr/local/bin/steam-compatibility @@ -1117,7 +1117,6 @@ def game_requests(directory=None): def read_game_requests(directory=None): - """The app's per-game Proton choices, and the file's text so a rewrite can tell the app wrote it since.""" try: with open(state_path("games.json", directory), encoding="utf-8") as f: text = f.read() @@ -1130,7 +1129,6 @@ def read_game_requests(directory=None): def write_game_requests(requests, original, directory=None): - """Rewrites games.json unless the app replaced it after it was read; that newer choice wins.""" path = state_path("games.json", directory) try: with open(path, encoding="utf-8") as f: @@ -1149,17 +1147,10 @@ def valid_game_request(app, request): def settle_game_requests(requests, state, current, catalog, missing_is_cleared=False): - """Whichever side changed a game's tool last wins. A choice the app makes is applied once; after - that the client's own pick stands: a pick of one of our Protons becomes the app's choice too, any - other pick (FEX, a Steam Linux Runtime, clearing the forced tool) ends the app's choice. "Use the - default" (null, also what a profile reset writes) is applied once and then dropped, so the game - follows the default like one the app never touched. Returns the new requests and whether they changed.""" recorded = state.get("games_applied") if isinstance(recorded, dict): applied = dict(recorded) else: - # Builds before this one kept no record but had already applied every choice they found; a - # first start has applied nothing yet. applied = dict(requests) if "tools" in state else {} by_name = {t["name"]: t for t in catalog} out = dict(requests) @@ -1189,9 +1180,6 @@ def settle_game_requests(requests, state, current, catalog, missing_is_cleared=F def settled_game_requests(state, current, catalog, missing_is_cleared=False, directory=None): - """Reads the app's choices, settles them against the client's picks and stores the result. When the - app rewrote the file meanwhile, its newer choices are settled again; they are never overwritten. - None when the app kept rewriting it: this pass then applies no per-game choice and records nothing.""" for _ in range(2): requests, text = read_game_requests(directory) settled, moved = settle_game_requests(requests, state, current, catalog, missing_is_cleared) @@ -1201,8 +1189,6 @@ def settled_game_requests(state, current, catalog, missing_is_cleared=False, dir def applied_game_requests(requests, overrides, state): - """What the app asked for that is now in the mapping. A tool not installed yet stays unapplied, and a - game this pass did not handle (the client refused to report it) keeps what was applied before.""" recorded = state.get("games_applied") before = recorded if isinstance(recorded, dict) else {} if requests is None: @@ -1399,7 +1385,6 @@ def register_default(config_path, apps=(), protect=(), default=None, catalog=Non else: default = state.get("default") if state.get("default") in names else TOOL steam_root = os.path.dirname(os.path.dirname(os.path.abspath(config_path))) - # With the client stopped, config.vdf is what it last kept: an app missing from it has no forced tool. requests = settled_game_requests(state, current, catalog, not steam_running(), directory) overrides = requested_games(requests or {}, catalog) managed = state.get("auto", {}).keys() if isinstance(state.get("auto"), dict) else () From 06946fe8ae7fddff9e68ece65e23458f4595f6f8 Mon Sep 17 00:00:00 2001 From: maxjivi05 Date: Fri, 23 Oct 2026 16:01:41 -0400 Subject: [PATCH 52/59] chore: drop comments --- app/src/main/cpp/waylandcomp/src/compositor.c | 6 ------ 1 file changed, 6 deletions(-) diff --git a/app/src/main/cpp/waylandcomp/src/compositor.c b/app/src/main/cpp/waylandcomp/src/compositor.c index fb15d50c3..1be637a7b 100644 --- a/app/src/main/cpp/waylandcomp/src/compositor.c +++ b/app/src/main/cpp/waylandcomp/src/compositor.c @@ -807,12 +807,6 @@ static uint32_t nominal_refresh_ns(void) { return (uint32_t)(1000000000000LL / (int64_t)mhz); } -/* Gamescope paces from this timestamp (#717). The Choreographer tick is often still - * slightly ahead of this thread, because the UI thread queues the callback, and that - * used to throw the tick away (render_ns < tick) and report the draw time instead. - * Keep a tick within one refresh of the draw, ahead or behind. A missing tick, or one - * more than a refresh away, falls back to the draw time. Never report the next refresh: - * gamescope would wake a frame late. */ static int64_t presented_ns(int64_t render_ns) { int64_t tick = g_tick_vsync_ns; int64_t refresh = (int64_t)nominal_refresh_ns(); From 7eb4717d4c8919688d74b294d0db538a247e5b5d Mon Sep 17 00:00:00 2001 From: maxjivi05 Date: Fri, 23 Oct 2026 16:16:10 -0400 Subject: [PATCH 53/59] chore: drop the comments added with the Unity TSO fix --- tools/linuxfs/overlay/usr/local/bin/droiddeck-fex | 1 - tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env | 3 --- 2 files changed, 4 deletions(-) diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-fex b/tools/linuxfs/overlay/usr/local/bin/droiddeck-fex index aaa4830ce..6ca792143 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-fex +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-fex @@ -683,7 +683,6 @@ def game_environment(env, command): game = game_env["Game"](resolve(command[0], env), command[1:]) for check in game_env["FIXES"]: found = check(game) - # Environment fixes only: the launch arguments some fixes add were chosen for Windows builds. if found and found[0]: fixes.update(found[0]) say(found[2]) diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env b/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env index 22b5248f3..75154ab6d 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env @@ -89,9 +89,6 @@ def dotnet_memory_ordering(game): @fix def unity_memory_ordering(game): - """A Unity player under FEX without TSO deadlocks or crashes: Megabonk (Linux, IL2CPP) froze with every - thread parked on a futex on about half of all launches, Crab Game (Windows under Proton, IL2CPP) died with - an access violation in UnityPlayer.dll. Presets that turn TSO off keep it on for Unity games.""" if {"unityplayer.so", "gameassembly.so", "unityplayer.dll"} & game.files: return {"FEX_TSOENABLED": "1"}, [], "Unity: FEX with TSO" From c95e114766adb7b2ef585302e9474c5cb2ed53bf Mon Sep 17 00:00:00 2001 From: XxJSONDeruloxX Date: Fri, 23 Oct 2026 16:20:42 -0400 Subject: [PATCH 54/59] fix(ci): recover directory cache entries --- tools/packs/fetch-verified.sh | 3 +++ tools/tests/test_pack_downloads.py | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/tools/packs/fetch-verified.sh b/tools/packs/fetch-verified.sh index 0b8c03977..41bba2819 100644 --- a/tools/packs/fetch-verified.sh +++ b/tools/packs/fetch-verified.sh @@ -22,6 +22,9 @@ else curl -fsSL --retry 4 --retry-all-errors --connect-timeout 15 --max-time 180 --retry-max-time 240 \ -o "$part" "$url" verify "$part" || { echo "Checksum mismatch for $url" >&2; exit 1; } + if [ -d "$kept" ]; then + rm -rf -- "$kept" + fi mv -f "$part" "$kept" echo "Downloaded and verified pack input: $expected" fi diff --git a/tools/tests/test_pack_downloads.py b/tools/tests/test_pack_downloads.py index 20547c03b..0b5eb6060 100644 --- a/tools/tests/test_pack_downloads.py +++ b/tools/tests/test_pack_downloads.py @@ -51,6 +51,25 @@ class PackDownloadsTest(unittest.TestCase): self.assertEqual(len(self.log.read_text().splitlines()), 2) self.assertEqual((self.cache / self.checksum).read_bytes(), b'verified input') + def test_directory_cache_entry_is_replaced_only_after_verification(self): + entry = self.cache / self.checksum + entry.mkdir() + sentinel = entry / 'old-entry' + sentinel.write_bytes(b'preserve until verified') + self.remote.write_bytes(b'tampered download') + self.assertNotEqual(self.fetch().returncode, 0) + self.assertEqual(sentinel.read_bytes(), b'preserve until verified') + self.assertFalse(self.destination.exists()) + self.remote.write_bytes(b'verified input') + result = self.fetch() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue(entry.is_file()) + self.assertEqual(entry.read_bytes(), b'verified input') + self.assertEqual(self.destination.read_bytes(), b'verified input') + self.assertEqual(list(self.cache.iterdir()), [entry]) + self.assertEqual(self.fetch().returncode, 0) + self.assertEqual(len(self.log.read_text().splitlines()), 2) + def test_changed_pin_cannot_reuse_old_bytes(self): self.assertEqual(self.fetch().returncode, 0) self.remote.write_bytes(b'new approved input') From 992912fdf87c970a4c2a6135fe98bb4727f15a83 Mon Sep 17 00:00:00 2001 From: maxjivi05 Date: Fri, 23 Oct 2026 17:43:44 -0400 Subject: [PATCH 55/59] fix: other apps can record game audio --- app/src/main/AndroidManifest.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index fcd9b2e11..286a9ee8a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -34,6 +34,7 @@ android:localeConfig="@xml/locales_config" android:networkSecurityConfig="@xml/network_security_config" android:allowBackup="false" + android:allowAudioPlaybackCapture="true" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:theme="@style/AppTheme" From 94b530ecb2a32efe6b92a67a97ea6cf6bc38e72a Mon Sep 17 00:00:00 2001 From: maxjivi05 Date: Fri, 23 Oct 2026 18:28:35 -0400 Subject: [PATCH 56/59] fix: stored components can be deleted after a Proton update --- .../launcher/session/ComponentsManager.kt | 52 +++++++++++++------ .../session/ComponentsSelectionTest.kt | 32 ++++++++++++ 2 files changed, 67 insertions(+), 17 deletions(-) create mode 100644 app/src/test/java/com/droiddeck/launcher/session/ComponentsSelectionTest.kt diff --git a/app/src/main/java/com/droiddeck/launcher/session/ComponentsManager.kt b/app/src/main/java/com/droiddeck/launcher/session/ComponentsManager.kt index d1aae9d72..d05ba2836 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/ComponentsManager.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/ComponentsManager.kt @@ -721,13 +721,19 @@ object ComponentsManager { class StoredOriginal(val protonId: String, val protonName: String?, val comp: String, val protonVersion: String, val size: Long, val kept: Boolean) class Stored(val packages: List, val originals: List) - private fun packagesInUse(state: JSONObject): Set { + internal fun liveSelection(key: String, entry: JSONObject, proton: Proton?): Boolean = + proton != null && (key == "queued" || entry.optString("protonVersion") == proton.version) + + private fun packagesInUse(state: JSONObject, installed: Map): Set { val names = HashSet() for (key in listOf("active", "queued")) { val byProton = state.optJSONObject(key) ?: continue for (pid in byProton.keys()) { val comps = byProton.optJSONObject(pid) ?: continue - for (comp in comps.keys()) comps.optJSONObject(comp)?.optString("file")?.takeIf { it.isNotEmpty() }?.let(names::add) + for (comp in comps.keys()) { + val entry = comps.optJSONObject(comp) ?: continue + if (liveSelection(key, entry, installed[pid])) entry.optString("file").takeIf { it.isNotEmpty() }?.let(names::add) + } } } val profiles = state.optJSONObject("profiles") @@ -747,8 +753,8 @@ object ComponentsManager { fun stored(context: Context): Stored = synchronized(lock) { val state = loadState(context) - val used = packagesInUse(state) val installed = protons(context).associateBy { it.id } + val used = packagesInUse(state, installed) val originals = originalsDir(context).listFiles()?.filter { it.isDirectory }?.flatMap { proton -> proton.listFiles()?.filter { it.isDirectory }?.flatMap { version -> COMPONENTS.mapNotNull { comp -> @@ -777,23 +783,35 @@ object ComponentsManager { fun deletePackage(context: Context, file: String): String = synchronized(lock) { val name = safeName(file) val state = loadState(context) - val queued = state.sub("queued") - for (pid in queued.keys()) { - val comps = queued.optJSONObject(pid) ?: continue - for (comp in comps.keys()) check(comps.optJSONObject(comp)?.optString("file") != name) { context.getString(R.string.cmgr_package_in_use, name, pid) } - } - val active = state.sub("active") - for (pid in active.keys()) { - val comps = active.optJSONObject(pid) ?: continue - for (comp in comps.keys()) check(comps.optJSONObject(comp)?.optString("file") != name) { context.getString(R.string.cmgr_package_in_use, name, pid) } - } - val profiles = state.sub("profiles") - for (scope in profiles.keys()) { - val profile = profiles.optJSONObject(scope) ?: continue - check(COMPONENTS.none { profile.optString(it) == name }) { context.getString(R.string.cmgr_package_in_use, name, scope) } + val installed = protons(context).associateBy { it.id } + var changed = false + for (key in listOf("queued", "active")) { + val byProton = state.sub(key) + for (pid in byProton.keys().asSequence().toList()) { + val comps = byProton.optJSONObject(pid) ?: continue + for (comp in comps.keys().asSequence().toList()) { + val entry = comps.optJSONObject(comp) ?: continue + if (entry.optString("file") != name) continue + check(!liveSelection(key, entry, installed[pid])) { context.getString(R.string.cmgr_package_in_use, name, installed[pid]?.name ?: pid) } + comps.remove(comp) + changed = true + } + if (comps.length() == 0) byProton.remove(pid) + } } val f = File(packagesDir(context), name) check(f.isFile) { context.getString(R.string.cmgr_package_not_stored, name) } + val profiles = state.sub("profiles") + for (scope in profiles.keys().asSequence().toList()) { + val profile = profiles.optJSONObject(scope) ?: continue + for (comp in COMPONENTS.filter { profile.optString(it) == name }) { + rememberProfiledComponent(state, comp) + profile.remove(comp) + changed = true + } + if (profile.length() == 0) profiles.remove(scope) + } + if (changed) saveState(context, state) f.delete() context.getString(R.string.cmgr_deleted, name) } diff --git a/app/src/test/java/com/droiddeck/launcher/session/ComponentsSelectionTest.kt b/app/src/test/java/com/droiddeck/launcher/session/ComponentsSelectionTest.kt new file mode 100644 index 000000000..517d6ad2b --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/ComponentsSelectionTest.kt @@ -0,0 +1,32 @@ +package com.droiddeck.launcher.session + +import org.json.JSONObject +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.io.File + +@RunWith(RobolectricTestRunner::class) +class ComponentsSelectionTest { + private val proton = ComponentsManager.Proton("DroidDeck-Proton_11-11.1", "DroidDeck-Proton 11-11.1", File("/p"), "/p", "DroidDeck-Proton-11-11.1", false) + private val entry = JSONObject().put("file", "dxvk-low-latency-arm64ec-3.1.1-2-linux.wcp").put("protonVersion", "DroidDeck-Proton-11-11.1") + + @Test + fun `a package swapped into an installed Proton is in use`() { + assertTrue(ComponentsManager.liveSelection("active", entry, proton)) + assertTrue(ComponentsManager.liveSelection("queued", JSONObject().put("file", "x.wcp"), proton)) + } + + @Test + fun `a package left on a removed Proton is not in use`() { + assertFalse(ComponentsManager.liveSelection("active", entry, null)) + assertFalse(ComponentsManager.liveSelection("queued", entry, null)) + } + + @Test + fun `a package from before a Proton update is not in use`() { + assertFalse(ComponentsManager.liveSelection("active", JSONObject(entry.toString()).put("protonVersion", "DroidDeck-Proton-11-9.1"), proton)) + } +} From 70b8ec5de4abc2d8bceebc6d5beae87de0c80349 Mon Sep 17 00:00:00 2001 From: maxjivi05 Date: Fri, 23 Oct 2026 18:36:24 -0400 Subject: [PATCH 57/59] docs: add the Trendshift #1 daily and weekly badges to the README --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index 7fd8cfbdb..7d9081593 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,16 @@ DroidDeck brings the SteamOS experience to Android: Valve's Steam client in Big

Join the DroidDeck Discord

+

+ Droid-Deck%2FDroidDeck | Trendshift + Droid-Deck%2FDroidDeck | Trendshift +

+ +

+ Droid-Deck%2FDroidDeck | Trendshift + Droid-Deck%2FDroidDeck | Trendshift +

+

Tapping DroidDeck on the Android home screen and landing in Steam Big Picture

> Note: DroidDeck does not have a stand-alone website. Do not click on any download links from websites claiming to be the DroidDeck team. From db9aae7b876a297eeed2c0ab5b6defb3a946b451 Mon Sep 17 00:00:00 2001 From: maxjivi05 Date: Fri, 23 Oct 2026 18:56:24 -0400 Subject: [PATCH 58/59] fix: delete the package before saving, and match Protons the same way --- .../com/droiddeck/launcher/session/ComponentsManager.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/session/ComponentsManager.kt b/app/src/main/java/com/droiddeck/launcher/session/ComponentsManager.kt index d05ba2836..776a9f7a0 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/ComponentsManager.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/ComponentsManager.kt @@ -753,7 +753,7 @@ object ComponentsManager { fun stored(context: Context): Stored = synchronized(lock) { val state = loadState(context) - val installed = protons(context).associateBy { it.id } + val installed = protons(context).distinctBy { it.id }.associateBy { it.id } val used = packagesInUse(state, installed) val originals = originalsDir(context).listFiles()?.filter { it.isDirectory }?.flatMap { proton -> proton.listFiles()?.filter { it.isDirectory }?.flatMap { version -> @@ -783,7 +783,7 @@ object ComponentsManager { fun deletePackage(context: Context, file: String): String = synchronized(lock) { val name = safeName(file) val state = loadState(context) - val installed = protons(context).associateBy { it.id } + val installed = protons(context).distinctBy { it.id }.associateBy { it.id } var changed = false for (key in listOf("queued", "active")) { val byProton = state.sub(key) @@ -811,8 +811,8 @@ object ComponentsManager { } if (profile.length() == 0) profiles.remove(scope) } + check(f.delete()) { name } if (changed) saveState(context, state) - f.delete() context.getString(R.string.cmgr_deleted, name) } From c7bcd8278216ed5632cb852b31b0b4ec468220cf Mon Sep 17 00:00:00 2001 From: maxjivi05 Date: Fri, 23 Oct 2026 19:47:41 -0400 Subject: [PATCH 59/59] fix: Snapdragon 8 Gen 2 no longer pins Steam to one core by default --- .../launcher/core/CpuAllocationDefaults.kt | 27 ------------ .../launcher/session/SessionPrefs.kt | 18 ++------ .../core/CpuAllocationDefaultsTest.kt | 42 ------------------- .../session/CoreAllocationPrefsTest.kt | 37 +++++----------- 4 files changed, 14 insertions(+), 110 deletions(-) delete mode 100644 app/src/main/java/com/droiddeck/launcher/core/CpuAllocationDefaults.kt delete mode 100644 app/src/test/java/com/droiddeck/launcher/core/CpuAllocationDefaultsTest.kt diff --git a/app/src/main/java/com/droiddeck/launcher/core/CpuAllocationDefaults.kt b/app/src/main/java/com/droiddeck/launcher/core/CpuAllocationDefaults.kt deleted file mode 100644 index adaa106a0..000000000 --- a/app/src/main/java/com/droiddeck/launcher/core/CpuAllocationDefaults.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.droiddeck.launcher.core - -import com.droiddeck.launcher.gpu.GpuInfo -import com.droiddeck.launcher.gpu.SocNames - -object CpuAllocationDefaults { - data class Allocation(val client: String, val game: String) - - val device: Allocation? by lazy { - forDevice(SocNames.model(), GpuInfo.systemProperty("ro.board.platform"), - CpuCores.all.associateWith(CpuCores::maxGhz)) - } - - fun forDevice(model: String, platform: String, frequencies: Map): Allocation? { - val chip = model.trim().uppercase().substringBefore('-') - val gen2 = chip in setOf("SM8550", "QCS8550") || - (chip.isEmpty() && platform.trim().equals("kalama", ignoreCase = true)) - if (!gen2 || frequencies.size != 8 || frequencies.keys.any { it < 0 } || - frequencies.values.any { it == null || !it.isFinite() || it <= 0.0 }) return null - val clusters = frequencies.entries.groupBy { it.value }.toSortedMap(compareBy { it ?: 0.0 }).values.toList() - if (clusters.map { it.size } != listOf(3, 4, 1)) return null - return Allocation( - client = CpuCores.format(clusters[2].map { it.key }.toSet()), - game = CpuCores.format(clusters[1].map { it.key }.toSet()), - ) - } -} diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt index aaf084c32..e8041fe0b 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -4,7 +4,6 @@ import android.content.Context import android.content.pm.ActivityInfo import androidx.annotation.StringRes import com.droiddeck.launcher.R -import com.droiddeck.launcher.core.CpuAllocationDefaults import com.droiddeck.launcher.core.GameEnvironment import com.droiddeck.launcher.core.TextureFiltering import com.droiddeck.launcher.gpu.Cardboard @@ -382,30 +381,19 @@ object SessionPrefs { prefs(context).edit().putBoolean("wifiDiscoveryAsked", true).apply() } - internal data class CoreAllocation(val clientOverride: Boolean, val clientCpus: String, val gameCpus: String) - - internal fun coreAllocation(context: Context, defaults: CpuAllocationDefaults.Allocation? = CpuAllocationDefaults.device): CoreAllocation { - val saved = prefs(context) - return CoreAllocation( - saved.getBoolean("clientCpusOverride", defaults != null && !saved.contains("clientCpus")), - saved.getString("clientCpus", defaults?.client.orEmpty()).orEmpty(), - saved.getString("gameCpus", defaults?.game.orEmpty()).orEmpty(), - ) - } - - fun clientCpusOverride(context: Context): Boolean = coreAllocation(context).clientOverride + fun clientCpusOverride(context: Context): Boolean = prefs(context).getBoolean("clientCpusOverride", false) fun setClientCpusOverride(context: Context, on: Boolean) { prefs(context).edit().putBoolean("clientCpusOverride", on).apply() } - fun clientCpus(context: Context): String = coreAllocation(context).clientCpus + fun clientCpus(context: Context): String = prefs(context).getString("clientCpus", "").orEmpty() fun setClientCpus(context: Context, list: String) { prefs(context).edit().putString("clientCpus", list).apply() } - fun gameCpus(context: Context): String = coreAllocation(context).gameCpus + fun gameCpus(context: Context): String = prefs(context).getString("gameCpus", "").orEmpty() fun setGameCpus(context: Context, list: String) { prefs(context).edit().putString("gameCpus", list).apply() diff --git a/app/src/test/java/com/droiddeck/launcher/core/CpuAllocationDefaultsTest.kt b/app/src/test/java/com/droiddeck/launcher/core/CpuAllocationDefaultsTest.kt deleted file mode 100644 index ba6e31a10..000000000 --- a/app/src/test/java/com/droiddeck/launcher/core/CpuAllocationDefaultsTest.kt +++ /dev/null @@ -1,42 +0,0 @@ -package com.droiddeck.launcher.core - -import org.junit.Assert.* -import org.junit.Test - -class CpuAllocationDefaultsTest { - private val thor = (0..7).associateWith { when (it) { in 0..2 -> 2.016; in 3..6 -> 2.8032; else -> 3.1872 } } - - @Test fun gen2UsesTheFourMiddleCoresAndOnePrime() { - for (model in listOf("SM8550", "QCS8550", "sm8550-ac")) { - assertEquals(CpuAllocationDefaults.Allocation("7", "3,4,5,6"), CpuAllocationDefaults.forDevice(model, "", thor)) - } - assertEquals(CpuAllocationDefaults.Allocation("7", "3,4,5,6"), CpuAllocationDefaults.forDevice("", "kalama", thor)) - } - - @Test fun coreNumbersAndClockVariantsAreDetectedInsteadOfHardcoded() { - val reordered = mapOf(0 to 3.36, 1 to 2.0, 2 to 2.0, 3 to 2.0, 4 to 2.8, 5 to 2.8, 6 to 2.8, 7 to 2.8) - assertEquals(CpuAllocationDefaults.Allocation("0", "4,5,6,7"), CpuAllocationDefaults.forDevice("SM8550-AC", "kalama", reordered)) - } - - @Test fun anotherOrUnknownChipDoesNotAcquireThisDefault() { - assertNull(CpuAllocationDefaults.forDevice("SM8650", "pineapple", thor)) - assertNull(CpuAllocationDefaults.forDevice("SM8650", "kalama", thor)) - assertNull(CpuAllocationDefaults.forDevice("", "", thor)) - } - - @Test fun missingOrUnexpectedTopologyKeepsTheSchedulerDefault() { - assertNull(CpuAllocationDefaults.forDevice("SM8550", "kalama", thor - 7)) - assertNull(CpuAllocationDefaults.forDevice("SM8550", "kalama", thor + (7 to null))) - assertNull(CpuAllocationDefaults.forDevice("SM8550", "kalama", thor + (7 to 0.0))) - assertNull(CpuAllocationDefaults.forDevice("SM8550", "kalama", thor + (7 to Double.NaN))) - assertNull(CpuAllocationDefaults.forDevice("SM8550", "kalama", (0..7).associateWith { 2.8 })) - } - - @Test fun allCoreGamesUndoTheClientsInheritedRestriction() { - val all = CpuCores.listOrAll("") - assertEquals(all, CpuCores.gameLaunchList("", clientPinned = true)) - assertEquals(all, CpuCores.gameLaunchList(all, clientPinned = true)) - assertEquals("", CpuCores.gameLaunchList("", clientPinned = false)) - assertEquals("0", CpuCores.gameLaunchList("0", clientPinned = true)) - } -} diff --git a/app/src/test/java/com/droiddeck/launcher/session/CoreAllocationPrefsTest.kt b/app/src/test/java/com/droiddeck/launcher/session/CoreAllocationPrefsTest.kt index 8a25bf4e7..901a82d3f 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/CoreAllocationPrefsTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/CoreAllocationPrefsTest.kt @@ -1,7 +1,6 @@ package com.droiddeck.launcher.session import android.content.Context -import com.droiddeck.launcher.core.CpuAllocationDefaults import org.junit.Assert.* import org.junit.Before import org.junit.Test @@ -14,36 +13,22 @@ import org.robolectric.annotation.Config @Config(sdk = [33]) class CoreAllocationPrefsTest { private val app get() = RuntimeEnvironment.getApplication() - private val defaults = CpuAllocationDefaults.Allocation("7", "3,4,5,6") private val saved get() = app.getSharedPreferences("session", Context.MODE_PRIVATE) @Before fun clear() { saved.edit().clear().commit() } - @Test fun absentPreferencesUseTheDeviceDefaultWithoutWritingIt() { - assertEquals(SessionPrefs.CoreAllocation(true, "7", "3,4,5,6"), SessionPrefs.coreAllocation(app, defaults)) - assertTrue(saved.all.isEmpty()) - assertEquals(SessionPrefs.CoreAllocation(false, "", ""), SessionPrefs.coreAllocation(app, null)) + @Test fun absentPreferencesLeaveEveryCoreToTheScheduler() { + assertFalse(SessionPrefs.clientCpusOverride(app)) + assertEquals("", SessionPrefs.clientCpus(app)) + assertEquals("", SessionPrefs.gameCpus(app)) } - @Test fun explicitDisableAndAllCoreChoicesSurviveAnUpdate() { - saved.edit().putBoolean("clientCpusOverride", false).putString("clientCpus", "").putString("gameCpus", "").commit() - assertEquals(SessionPrefs.CoreAllocation(false, "", ""), SessionPrefs.coreAllocation(app, defaults)) - } - - @Test fun existingCustomMasksArePreserved() { - saved.edit().putBoolean("clientCpusOverride", true).putString("clientCpus", "0,1").putString("gameCpus", "2,7").commit() - assertEquals(SessionPrefs.CoreAllocation(true, "0,1", "2,7"), SessionPrefs.coreAllocation(app, defaults)) - } - - @Test fun anOldClientMaskIsNotEnabledWithoutAnOverrideChoice() { - saved.edit().putString("clientCpus", "0,1").commit() - assertEquals(SessionPrefs.CoreAllocation(false, "0,1", "3,4,5,6"), SessionPrefs.coreAllocation(app, defaults)) - } - - @Test fun changingOneChoiceDoesNotReplaceTheOtherDefaults() { - SessionPrefs.setGameCpus(app, "0,1,2,3,4,5,6,7") - assertEquals(SessionPrefs.CoreAllocation(true, "7", "0,1,2,3,4,5,6,7"), SessionPrefs.coreAllocation(app, defaults)) - SessionPrefs.setClientCpusOverride(app, false) - assertEquals(SessionPrefs.CoreAllocation(false, "7", "0,1,2,3,4,5,6,7"), SessionPrefs.coreAllocation(app, defaults)) + @Test fun savedChoicesAreKept() { + SessionPrefs.setClientCpusOverride(app, true) + SessionPrefs.setClientCpus(app, "7") + SessionPrefs.setGameCpus(app, "3,4,5,6") + assertTrue(SessionPrefs.clientCpusOverride(app)) + assertEquals("7", SessionPrefs.clientCpus(app)) + assertEquals("3,4,5,6", SessionPrefs.gameCpus(app)) } }