mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-14 19:27:09 -04:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
911ad29004 | ||
|
|
8cdedbe18a | ||
|
|
0f39d71f95 | ||
|
|
1dc49b527b | ||
|
|
cc3fec0768 | ||
|
|
5270574e5a | ||
|
|
a8af973726 | ||
|
|
ed1f8dd2b6 | ||
|
|
e8aaae1343 |
No files matched your search
@@ -3210,23 +3210,32 @@ static void touch_down(int id, double x, double y) {
|
||||
struct surface *target = touch_target_at(x, y);
|
||||
if (!target) return;
|
||||
struct wl_client *client = wl_resource_get_client(target->resource);
|
||||
/* A down on an id that is still held means that sequence's up was lost: end it first. The
|
||||
* fingers still down are counted only after that - counted before, a lost up left any_active
|
||||
* set, and on the pointer path every later touch was dropped (a tap or swipe did nothing). */
|
||||
if (g_touch_points[id].surface) touch_cancel_client(client);
|
||||
int any_active = 0;
|
||||
for (int i = 0; i < MAX_TOUCH_POINTS; i++) {
|
||||
struct surface *active = g_touch_points[i].surface;
|
||||
if (!active) continue;
|
||||
any_active = 1;
|
||||
if (active != target) {
|
||||
touch_cancel_all();
|
||||
any_active = 0;
|
||||
break;
|
||||
}
|
||||
any_active = 1;
|
||||
}
|
||||
if (g_touch_points[id].surface) touch_cancel_client(client);
|
||||
struct seat_touch *st;
|
||||
int has_touch = 0;
|
||||
for_each_touch_of(client, st) has_touch = 1;
|
||||
/* One line per gesture (first finger), so a test can see where a touch went. */
|
||||
if (!any_active) {
|
||||
char name[160];
|
||||
describe(target, name, sizeof(name));
|
||||
banner_log("touch", "%s gets %s", name, has_touch ? "touch" : "the pointer (it takes no touch)");
|
||||
}
|
||||
if (!has_touch) {
|
||||
if (any_active) return;
|
||||
if (any_active) return; /* one finger drives the pointer; a second one is ignored */
|
||||
g_touch_points[id].surface = target;
|
||||
g_touch_points[id].pointer_fallback = 1;
|
||||
pointer_event(x, y, BTN_LEFT, 1);
|
||||
|
||||
@@ -29,6 +29,7 @@ import com.droiddeck.launcher.gpu.LsfgNative
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.runtime.DesktopCatalog
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntimeInstaller
|
||||
import com.droiddeck.launcher.runtime.DeckyManager
|
||||
import com.droiddeck.launcher.session.SessionService
|
||||
import com.droiddeck.launcher.ui.PackageRow
|
||||
import com.droiddeck.launcher.session.OfflineMode
|
||||
@@ -88,6 +89,13 @@ class MainActivity : ComponentActivity() {
|
||||
private var steamDeckMode by mutableStateOf(false)
|
||||
private var showCredits by mutableStateOf(false)
|
||||
private var showProtons by mutableStateOf(false)
|
||||
private var deckyInstalled by mutableStateOf<String?>(null)
|
||||
private var deckyReleases by mutableStateOf<List<DeckyManager.Release>>(emptyList())
|
||||
private var deckyChecking by mutableStateOf(false)
|
||||
private var deckyStage by mutableStateOf<String?>(null)
|
||||
private var deckyPercent by mutableIntStateOf(-1)
|
||||
private var deckySupervisor by mutableStateOf(false)
|
||||
private var deckyReleaseRequest = 0
|
||||
private var showMapping by mutableStateOf(false)
|
||||
private var controllerSettings by mutableStateOf<ControllerPrefs.Settings?>(null)
|
||||
private var catalog by mutableStateOf<List<DesktopCatalog.Entry>?>(null)
|
||||
@@ -416,6 +424,9 @@ class MainActivity : ComponentActivity() {
|
||||
refreshHomeAppState()
|
||||
refreshSecondScreenDisplays()
|
||||
refresh()
|
||||
deckyInstalled = DeckyManager.installed(this)
|
||||
DeckyManager.syncCefMarker(this)
|
||||
deckySupervisor = DeckyManager.supervisorEnabled(this)
|
||||
// Added games' art (a store lookup for what the folders lack) starts here, not only when
|
||||
// the cog opens.
|
||||
refreshAddedGames()
|
||||
@@ -486,6 +497,37 @@ class MainActivity : ComponentActivity() {
|
||||
refreshProtons()
|
||||
}
|
||||
|
||||
private fun refreshDecky() {
|
||||
val request = ++deckyReleaseRequest
|
||||
deckyChecking = true
|
||||
Thread({
|
||||
val channels = runCatching { DeckyManager.releaseChannels(this) }
|
||||
.getOrElse { DeckyManager.ReleaseChannels(emptyList(), emptyList()) }
|
||||
ui.post {
|
||||
if (request == deckyReleaseRequest) {
|
||||
// Use the newest compatible stable build, or the newest compatible
|
||||
// prerelease when the fork has not published a stable ARM64 asset.
|
||||
deckyReleases = channels.stable.ifEmpty { channels.prerelease }
|
||||
deckyChecking = false
|
||||
}
|
||||
}
|
||||
}, "decky-releases").start()
|
||||
}
|
||||
|
||||
private fun installDecky(release: DeckyManager.Release) {
|
||||
if (deckyStage != null || SessionState.running) return
|
||||
deckyStage = "Starting…"; deckyPercent = -1
|
||||
Thread({
|
||||
val problem = runCatching {
|
||||
DeckyManager.install(this, release) { label, value -> ui.post { deckyStage = label; deckyPercent = value } }
|
||||
}.getOrElse { error -> "Decky install failed: ${error.message ?: error.javaClass.simpleName}" }
|
||||
ui.post {
|
||||
deckyStage = null; deckyPercent = -1; deckyInstalled = DeckyManager.installed(this)
|
||||
if (problem != null) android.widget.Toast.makeText(this, problem, android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}, "install-decky").start()
|
||||
}
|
||||
|
||||
private fun refreshController() {
|
||||
controllerSettings = ControllerPrefs.read(this)
|
||||
}
|
||||
@@ -623,6 +665,10 @@ class MainActivity : ComponentActivity() {
|
||||
addedGamesArt = addedGamesArt,
|
||||
linuxDownloads = linuxDownloads, androidDownloads = androidDownloads, releaseStatus = releaseStatus,
|
||||
releaseChecking = releaseChecking, canRestoreBundled = canRestoreBundled,
|
||||
deckyInstalled = if (mode == SessionService.MODE_STEAM) deckyInstalled else null,
|
||||
deckyLatestRelease = if (mode == SessionService.MODE_STEAM) deckyReleases.firstOrNull() else null,
|
||||
deckyChecking = deckyChecking, deckyStage = deckyStage, deckyPercent = deckyPercent,
|
||||
deckyEnabled = deckySupervisor, deckySessionRunning = SessionState.running,
|
||||
),
|
||||
ModeSettingsActions(
|
||||
onResolution = { cap -> SessionPrefs.setResolutionCap(this, mode, cap); resolutionCap = cap },
|
||||
@@ -672,6 +718,14 @@ class MainActivity : ComponentActivity() {
|
||||
pendingAddedGame = folder
|
||||
pickAddedGameExe.launch(InAppFilePicker.buildIntent(this, listOf("exe"), "Choose the game's .exe", folder))
|
||||
},
|
||||
onDeckyInstall = { release -> installDecky(release) },
|
||||
onDeckyCheck = { refreshDecky() },
|
||||
onDeckyEnabled = { enabled -> DeckyManager.setSupervisorEnabled(this, enabled); deckySupervisor = enabled },
|
||||
onDeckyUninstall = {
|
||||
DeckyManager.uninstall(this, wipeData = false)
|
||||
deckyInstalled = null
|
||||
deckySupervisor = false
|
||||
},
|
||||
onDismiss = { settingsMode = null },
|
||||
),
|
||||
)
|
||||
@@ -749,6 +803,11 @@ class MainActivity : ComponentActivity() {
|
||||
renderer = SessionPrefs.desktopRenderer(this)
|
||||
gameStorage = SessionPrefs.gameStorage(this)
|
||||
storageOptions = GameStorage.options(this).map { it.label to it.path }
|
||||
if (mode == SessionService.MODE_STEAM) {
|
||||
deckyInstalled = DeckyManager.installed(this)
|
||||
deckySupervisor = DeckyManager.supervisorEnabled(this)
|
||||
refreshDecky()
|
||||
}
|
||||
settingsMode = mode
|
||||
}
|
||||
|
||||
|
||||
@@ -1231,7 +1231,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
WaylandCompositor.nativeSendTouch(action, event.getPointerId(index), (x * 1919f).toInt(), (y * 1079f).toInt())
|
||||
}
|
||||
when (event.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN -> sendTouch(0, event.actionIndex)
|
||||
// A first finger starts a new gesture, and Android has ended every earlier one: whatever
|
||||
// the compositor still holds (an up lost to the drawer opening mid-touch) goes first.
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
WaylandCompositor.nativeSendTouch(3, -1, 0, 0)
|
||||
sendTouch(0, event.actionIndex)
|
||||
}
|
||||
MotionEvent.ACTION_POINTER_DOWN -> sendTouch(0, event.actionIndex)
|
||||
MotionEvent.ACTION_MOVE -> for (index in 0 until event.pointerCount) sendTouch(1, index)
|
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_POINTER_UP -> sendTouch(2, event.actionIndex)
|
||||
MotionEvent.ACTION_CANCEL -> WaylandCompositor.nativeSendTouch(3, -1, 0, 0)
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
package com.droiddeck.launcher.runtime
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.core.Downloader
|
||||
import com.droiddeck.launcher.session.SessionState
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.security.MessageDigest
|
||||
import org.json.JSONArray
|
||||
|
||||
/** Optional Decky Loader installation for the ARM64 Linux guest. */
|
||||
object DeckyManager {
|
||||
private const val TAG = "DeckyManager"
|
||||
const val REPOSITORY = "Droid-Deck/decky-loader"
|
||||
private const val RELEASES = "https://api.github.com/repos/$REPOSITORY/releases?per_page=30"
|
||||
private const val PATH = "root/homebrew/services/PluginLoader"
|
||||
private const val VERSION = "root/homebrew/services/.droiddeck-decky-version"
|
||||
private const val CEF_REMOTE_DEBUG_MARKER = "root/.local/share/Steam/.cef-enable-remote-debugging"
|
||||
|
||||
data class Release(val tag: String, val prerelease: Boolean, val asset: String, val url: String, val digest: String?, val shaUrl: String?, val size: Long, val machine: Int)
|
||||
data class ReleaseChannels(val stable: List<Release>, val prerelease: List<Release>)
|
||||
|
||||
fun installed(context: Context): String? {
|
||||
val loader = loader(context)
|
||||
if (!loader.isFile || !loader.canExecute()) return null
|
||||
return File(LinuxRuntime.rootDir(context), VERSION).takeIf { it.isFile }?.readText()?.trim()?.takeIf { it.isNotEmpty() } ?: "Installed"
|
||||
}
|
||||
|
||||
fun loader(context: Context) = File(LinuxRuntime.rootDir(context), PATH)
|
||||
fun supervisorEnabled(context: Context) = File(LinuxRuntime.rootDir(context), "root/.droiddeck-decky-enabled").isFile
|
||||
|
||||
/** Remove the legacy install-time debugger marker unless the user opted into Decky. */
|
||||
fun syncCefMarker(context: Context) {
|
||||
val marker = File(LinuxRuntime.rootDir(context), CEF_REMOTE_DEBUG_MARKER)
|
||||
if (!supervisorEnabled(context)) {
|
||||
marker.delete()
|
||||
return
|
||||
}
|
||||
runCatching {
|
||||
marker.parentFile?.mkdirs()
|
||||
marker.createNewFile()
|
||||
}
|
||||
}
|
||||
|
||||
fun setSupervisorEnabled(context: Context, enabled: Boolean) {
|
||||
val marker = File(LinuxRuntime.rootDir(context), "root/.droiddeck-decky-enabled")
|
||||
val cefMarker = File(LinuxRuntime.rootDir(context), CEF_REMOTE_DEBUG_MARKER)
|
||||
if (enabled) {
|
||||
marker.parentFile?.mkdirs()
|
||||
marker.writeText("enabled\n")
|
||||
cefMarker.parentFile?.mkdirs()
|
||||
cefMarker.createNewFile()
|
||||
} else {
|
||||
marker.delete()
|
||||
cefMarker.delete()
|
||||
}
|
||||
}
|
||||
|
||||
fun releases(context: Context, prerelease: Boolean): List<Release> {
|
||||
val channels = releaseChannels(context)
|
||||
return if (prerelease) channels.prerelease else channels.stable
|
||||
}
|
||||
|
||||
fun releaseChannels(context: Context): ReleaseChannels {
|
||||
val body = Downloader.downloadString(RELEASES) ?: return ReleaseChannels(emptyList(), emptyList())
|
||||
return try {
|
||||
val array = JSONArray(body)
|
||||
val stable = mutableListOf<Release>()
|
||||
val prerelease = mutableListOf<Release>()
|
||||
for (i in 0 until array.length()) {
|
||||
val release = array.getJSONObject(i)
|
||||
if (release.optBoolean("draft", false)) continue
|
||||
val isPrerelease = release.optBoolean("prerelease", false)
|
||||
val assets = release.optJSONArray("assets") ?: continue
|
||||
val rows = (0 until assets.length()).map { assets.getJSONObject(it) }
|
||||
val arm = rows.firstOrNull { it.optString("name") == "PluginLoader-arm64" }
|
||||
val guest = LinuxRuntime.rootDir(context)
|
||||
val fexReady = File(guest, "usr/share/guestos/fex-mesa").isDirectory && File(guest, "usr/bin/FEX").canExecute()
|
||||
val binary = arm ?: if (fexReady) rows.firstOrNull { it.optString("name") == "PluginLoader" } else null
|
||||
val name = binary?.optString("name") ?: continue
|
||||
val url = binary.optString("browser_download_url").takeIf { it.startsWith("https://") } ?: continue
|
||||
val checksum = rows.firstOrNull {
|
||||
val n = it.optString("name")
|
||||
(n == "$name.sha256" || n == "$name.sha256sum" || n == "$name.sha256.txt") && it.optString("browser_download_url").startsWith("https://")
|
||||
}?.optString("browser_download_url")?.takeIf { it.startsWith("https://") }
|
||||
val digest = binary.optString("digest").takeIf { it.matches(Regex("(?i)sha256:[0-9a-f]{64}")) }
|
||||
if (digest == null && checksum == null) continue
|
||||
val size = binary.optLong("size", 0)
|
||||
if (size <= 0) continue
|
||||
val row = Release(release.optString("tag_name", "unknown"), isPrerelease, name, url, digest, checksum, size, if (name == "PluginLoader-arm64") 183 else 62)
|
||||
(if (isPrerelease) prerelease else stable).add(row)
|
||||
}
|
||||
ReleaseChannels(stable, prerelease)
|
||||
} catch (e: Exception) { Log.w(TAG, "release metadata", e); ReleaseChannels(emptyList(), emptyList()) }
|
||||
}
|
||||
|
||||
/** Downloads to a sibling temp file and atomically renames only after size, digest and ELF checks. */
|
||||
fun install(context: Context, release: Release, progress: (String, Int) -> Unit): String? {
|
||||
if (SessionState.running) return "Stop the active session before installing Decky Loader"
|
||||
val target = loader(context)
|
||||
target.parentFile?.mkdirs()
|
||||
val temp = File(target.parentFile, "PluginLoader.download")
|
||||
val ok = Downloader.downloadFile(release.url, temp, false) { f -> progress("Downloading ${release.tag}", if (f < 0) -1 else (f * 100).toInt().coerceIn(0, 100)) }
|
||||
if (!ok) { temp.delete(); return "Decky Loader download failed" }
|
||||
if (temp.length() != release.size) { temp.delete(); return "Decky Loader size did not match the release metadata" }
|
||||
val expected = release.digest?.substringAfter(':') ?: release.shaUrl?.let { checksumUrl ->
|
||||
val checksumBody = Downloader.downloadString(checksumUrl) ?: run { temp.delete(); return "Could not download the release checksum" }
|
||||
Regex("(?i)\\b[0-9a-f]{64}\\b").find(checksumBody)?.value
|
||||
} ?: run { temp.delete(); return "Release checksum is missing" }
|
||||
if (!expected.matches(Regex("(?i)[0-9a-f]{64}"))) { temp.delete(); return "Release checksum is invalid" }
|
||||
if (!expected.equals(sha256(temp), true)) { temp.delete(); return "Decky Loader checksum mismatch" }
|
||||
if (!validElf(temp, release.machine)) { temp.delete(); return "Release asset is not a compatible 64-bit Linux executable" }
|
||||
if (SessionState.running) { temp.delete(); return "A session started during the download; stop it before installing Decky Loader" }
|
||||
if (!temp.setExecutable(true, false)) { temp.delete(); return "Could not mark PluginLoader executable" }
|
||||
// Decky needs Steam's local CEF debugger only when its session supervisor is enabled.
|
||||
val cefMarker = File(LinuxRuntime.rootDir(context), CEF_REMOTE_DEBUG_MARKER)
|
||||
val cefReady = !supervisorEnabled(context) || cefMarker.isFile || runCatching {
|
||||
cefMarker.parentFile?.mkdirs()
|
||||
cefMarker.createNewFile() || cefMarker.isFile
|
||||
}.getOrDefault(false)
|
||||
if (!cefReady) { temp.delete(); return "Could not enable Steam CEF remote debugging" }
|
||||
val staged = File(target.parentFile, "PluginLoader.new")
|
||||
staged.delete()
|
||||
if (!temp.renameTo(staged)) { temp.delete(); return "Could not stage PluginLoader" }
|
||||
val old = File(target.parentFile, "PluginLoader.old")
|
||||
old.delete()
|
||||
if (target.exists() && !target.renameTo(old)) { staged.delete(); return "Could not preserve the existing PluginLoader" }
|
||||
if (!staged.renameTo(target)) {
|
||||
if (old.exists()) old.renameTo(target)
|
||||
staged.delete()
|
||||
return "Could not activate the new PluginLoader"
|
||||
}
|
||||
old.delete()
|
||||
File(target.parentFile, ".droiddeck-decky-version").writeText(release.tag + "\n")
|
||||
File(target.parentFile, ".droiddeck-decky-arch").writeText(if (release.machine == 183) "arm64\n" else "x86_64\n")
|
||||
if (!supervisorEnabled(context)) cefMarker.delete()
|
||||
return null
|
||||
}
|
||||
|
||||
fun uninstall(context: Context, wipeData: Boolean) {
|
||||
val root = LinuxRuntime.rootDir(context)
|
||||
setSupervisorEnabled(context, false)
|
||||
File(root, PATH).delete()
|
||||
File(root, VERSION).delete()
|
||||
File(root, "root/homebrew/services/.droiddeck-decky-arch").delete()
|
||||
File(root, CEF_REMOTE_DEBUG_MARKER).delete()
|
||||
if (wipeData) File(root, "root/homebrew").deleteRecursively()
|
||||
}
|
||||
|
||||
private fun sha256(file: File): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
FileInputStream(file).buffered().use { input ->
|
||||
val buffer = ByteArray(1024 * 1024)
|
||||
while (true) { val n = input.read(buffer); if (n < 0) break; digest.update(buffer, 0, n) }
|
||||
}
|
||||
return digest.digest().joinToString("") { "%02x".format(it.toInt() and 255) }
|
||||
}
|
||||
|
||||
private fun validElf(file: File, machine: Int): Boolean = runCatching {
|
||||
file.inputStream().use { input ->
|
||||
val h = ByteArray(20)
|
||||
if (input.read(h) != h.size) return false
|
||||
h[0] == 0x7f.toByte() && h[1] == 'E'.code.toByte() && h[2] == 'L'.code.toByte() && h[3] == 'F'.code.toByte() &&
|
||||
h[4] == 2.toByte() && h[5] == 1.toByte() && (h[18].toInt() and 255) + ((h[19].toInt() and 255) shl 8) == machine
|
||||
}
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
@@ -1,10 +1,16 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -15,6 +21,7 @@ import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.droiddeck.launcher.core.FexPreset
|
||||
import com.droiddeck.launcher.runtime.DeckyManager
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import com.droiddeck.launcher.session.SessionService
|
||||
|
||||
@@ -70,6 +77,14 @@ class ModeSettings(
|
||||
val releaseChecking: Boolean = false,
|
||||
/** A bundled display driver was deleted: the page offers to restore it. */
|
||||
val canRestoreBundled: Boolean = false,
|
||||
/** Steam only: Decky Loader is managed from the Steam session settings. */
|
||||
val deckyInstalled: String? = null,
|
||||
val deckyLatestRelease: DeckyManager.Release? = null,
|
||||
val deckyChecking: Boolean = false,
|
||||
val deckyStage: String? = null,
|
||||
val deckyPercent: Int = -1,
|
||||
val deckyEnabled: Boolean = false,
|
||||
val deckySessionRunning: Boolean = false,
|
||||
)
|
||||
|
||||
/** One added game as the settings page shows it: its folder, the chosen .exe, the other .exe files it could be. */
|
||||
@@ -109,6 +124,10 @@ class ModeSettingsActions(
|
||||
val onAddedGamesArt: (Boolean) -> Unit = {},
|
||||
val onAddedGameExe: (folderPath: String, path: String) -> Unit = { _, _ -> },
|
||||
val onPickAddedGameExe: (folderPath: String) -> Unit = {},
|
||||
val onDeckyInstall: (DeckyManager.Release) -> Unit = {},
|
||||
val onDeckyCheck: () -> Unit = {},
|
||||
val onDeckyEnabled: (Boolean) -> Unit = {},
|
||||
val onDeckyUninstall: () -> Unit = {},
|
||||
val onDismiss: () -> Unit,
|
||||
)
|
||||
|
||||
@@ -116,6 +135,7 @@ class ModeSettingsActions(
|
||||
fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
|
||||
val steam = s.mode == SessionService.MODE_STEAM
|
||||
val host = rememberMenuHost()
|
||||
var confirmDeckyRemoval by remember { mutableStateOf(false) }
|
||||
// The two driver lists open as full pages over this one ("rt" = runtime, "panel" = display).
|
||||
// Coming back restores this page as it was left: the same scroll position, and controller focus
|
||||
// on the driver box that opened the page.
|
||||
@@ -253,6 +273,58 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
|
||||
onPick = a.onSuspendPolicy,
|
||||
)
|
||||
}
|
||||
if (steam) SettingsGroup("Decky") {
|
||||
val updateAvailable = s.deckyInstalled != null && s.deckyLatestRelease != null &&
|
||||
s.deckyInstalled != s.deckyLatestRelease.tag
|
||||
val status = when {
|
||||
s.deckyStage != null -> s.deckyStage
|
||||
s.deckyChecking -> "Checking compatible releases…"
|
||||
s.deckyInstalled == null && s.deckyLatestRelease != null -> "Ready to install ${s.deckyLatestRelease.tag}."
|
||||
s.deckyInstalled == null -> "No compatible build found. Check again later."
|
||||
s.deckyLatestRelease == null -> "Installed · ${s.deckyInstalled}"
|
||||
updateAvailable -> "Update available · ${s.deckyLatestRelease.tag}"
|
||||
else -> "Up to date · ${s.deckyInstalled}"
|
||||
}
|
||||
SettingsRow("Loader", status) {
|
||||
val action = when {
|
||||
s.deckyStage != null -> "Working…"
|
||||
s.deckyChecking -> "Checking…"
|
||||
s.deckyInstalled == null && s.deckyLatestRelease != null -> "Install latest"
|
||||
s.deckyInstalled != null && updateAvailable -> "Update"
|
||||
else -> "Check"
|
||||
}
|
||||
Row(horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(6.dp)) {
|
||||
SecondaryButton(
|
||||
action,
|
||||
enabled = s.deckyStage == null && !s.deckyChecking && !s.deckySessionRunning,
|
||||
) {
|
||||
if (s.deckyLatestRelease == null || (s.deckyInstalled != null && !updateAvailable)) a.onDeckyCheck()
|
||||
else s.deckyLatestRelease?.let(a.onDeckyInstall)
|
||||
}
|
||||
if (s.deckyInstalled != null) SecondaryButton(
|
||||
"Uninstall",
|
||||
enabled = s.deckyStage == null && !s.deckySessionRunning,
|
||||
) { confirmDeckyRemoval = true }
|
||||
}
|
||||
}
|
||||
if (s.deckyInstalled != null) SettingsRow(
|
||||
"Decky",
|
||||
if (s.deckyEnabled) "Starts with Steam. Other apps on this device may be able to control Steam while enabled."
|
||||
else "Off. Decky stays off and Steam's remote debugging port stays closed.",
|
||||
) {
|
||||
Switch(
|
||||
checked = s.deckyEnabled,
|
||||
onCheckedChange = a.onDeckyEnabled,
|
||||
enabled = !s.deckySessionRunning && s.deckyStage == null,
|
||||
)
|
||||
}
|
||||
if (s.deckyStage != null && s.deckyPercent >= 0) {
|
||||
LinearProgressIndicator(
|
||||
progress = { s.deckyPercent / 100f },
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (steam && s.steamChannel != null) SettingsGroup("Client") {
|
||||
ChoiceRow(
|
||||
host, "channel", "Client branch", "The Steam client build the session forces. Applies at the next session start; the client may update itself once.",
|
||||
@@ -349,6 +421,15 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
|
||||
)
|
||||
}
|
||||
}
|
||||
if (confirmDeckyRemoval) AlertDialog(
|
||||
onDismissRequest = { confirmDeckyRemoval = false },
|
||||
title = { Text("Uninstall Decky Loader?") },
|
||||
text = { Text("Remove the loader and keep your plugins and settings.") },
|
||||
confirmButton = {
|
||||
TextButton(onClick = { confirmDeckyRemoval = false; a.onDeckyUninstall() }) { Text("Uninstall") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { confirmDeckyRemoval = false }) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
|
||||
/** The Resolution menu's "Custom…" entry. */
|
||||
|
||||
@@ -7,6 +7,44 @@ the timeline, then lessons and backlog. Companion to the README (what the app *d
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-25 - main `8cdedbe`: melonDS out of the box, touch in Big Picture, log privacy, Decky
|
||||
|
||||
State: **main = `8cdedbe`** (PR #22 merge). Device-tested on the AYANEO Pocket FIT (repacked test builds)
|
||||
unless marked. gamescope now staged from release **`gamescope-3.16.29-p3`**.
|
||||
|
||||
- **PR #18 → `9369dc3`** (`feat/melonds-preset`) - melonDS and the session:
|
||||
- **melonDS preset** (`bannerlator-pad-defaults`): the top screen as big as the panel allows with the bottom
|
||||
screen alone beside it, centred (Horizontal + Emphasize top; Hybrid repeats the top screen), sharp pixels;
|
||||
full screen from the front end (`-f`) at the panel's own resolution; the guide button toggles full
|
||||
screen, leaving to a 1x-wide 16:9 window whose menus gamescope stretches to a usable size; DS/DSi BIOS,
|
||||
firmware and NAND found in `ROMs/nds/{bios,firmware}` (BIOS by checksum). Existing installs moved once.
|
||||
- **Desktop**: `QT_QPA_PLATFORM="wayland;xcb"` (the melonDS AppImage ships only xcb and exited at once);
|
||||
a labwc window rule opens melonDS at 800x600, centred.
|
||||
- **HUD** follows the game window, not gamescope's 1x1 cursor or Qt menus (read 0 fps at 60).
|
||||
- **Cursor**: hides on pad / on-screen-control input (Bannerlator's 1.2 s rule), and shows the program's own
|
||||
shape - labwc's resize arrows, I-beams, a hide (Bannerlator's `wl_pointer.set_cursor` port; new here:
|
||||
labwc's GPU cursor is a UBWC dma-buf, read back through `vkp_image_readback`).
|
||||
- **Steam desktop switching**: "Steam Desktop UI" opens the desktop with Steam's desktop client in it (under
|
||||
gamescope the client picks Big Picture regardless); the drawer's **DESKTOP** button does it from Big
|
||||
Picture; relaunches restart the singleTop activity in place (`startActivity` from it was swallowed).
|
||||
Big Picture's own Power → Switch to Desktop still hangs: it waits on SteamOS Manager before its
|
||||
`steamos-session-select` fallback (stub included; a session bus did not help - tried and reverted).
|
||||
- **PR #19 → `ce0da01`** (`fix/log-privacy`) - no credentials or identities in session logs: the device's
|
||||
public addresses (the client's IPv6 check logs "external address" ~20x a session), `network.txt` addresses
|
||||
as their kind and the MAC masked, every Steam AccountName/PersonaName (plain account names leaked in the
|
||||
UI log's login lines), and every text file scrubbed at session end and in the share-logs zip. Verified:
|
||||
0 hits across 83 session folders.
|
||||
- **PR #16 → `5270574`** (Decky Loader, Kurt) - merged with the Steam CEF debugging marker tied to the
|
||||
supervisor toggle (it was created at install; on Android any app can reach 127.0.0.1:8080 - proven from
|
||||
another uid). Decky's own updater still points at the official project (no ARM64 build there).
|
||||
- **PR #20 → `036317f`** (Kurt) - local builds carry the CI payload (NDK proot, patched gamescope).
|
||||
- **PR #22 → `8cdedbe`** (`feat/steam-touch`) - touch in Big Picture: gamescope `0110` binds `wl_touch` in the
|
||||
nested backend and feeds wlserver's touch path (Big Picture sets Passthrough: tap and swipe rows as on a
|
||||
Deck); `0111` makes the nested pointer warp in Passthrough (touchpad mode: hover and tap-to-click work);
|
||||
the compositor's pointer fallback no longer jams on a lost finger-up.
|
||||
- **In design, not built**: the drawer's page dots as QAM-style icons (Display / Controls / Settings), the
|
||||
selected one "stepping forward" in a line; mock `droiddeck-drawer-tabs-preview.html`.
|
||||
|
||||
## 2026-09-24 (afternoon) - main `94f9935`: PS1, controller focus, own signing key, CI hardening
|
||||
|
||||
State: **main = `94f9935`** (PR #41 merge), main build run 36036219740 - the first one signed with
|
||||
|
||||
@@ -16,6 +16,17 @@ library list, before anything is published.
|
||||
3.16.29: realtime-priority Vulkan queues on request (`GAMESCOPE_FORCE_VULKAN_REALTIME=1`)
|
||||
without CAP_SYS_NICE, which proot can never have; and the gamepad-driven cursor sprite following
|
||||
the X pointer that XTest moves (it sat frozen).
|
||||
- `0110-wayland-backend-touch.patch` - this app: the nested Wayland backend bound only the host's
|
||||
pointer and keyboard, so a finger on the phone's screen never reached Steam. It now binds
|
||||
`wl_touch` too and hands each finger to wlserver's touch path (`wlserver_touchdown` / `motion` /
|
||||
`up`) - the one a Steam Deck's touchscreen drives - so what a touch does follows the client's
|
||||
touch mode (Steam's Big Picture sets Passthrough: a real touch, rows scroll under a finger).
|
||||
Finger ids are offset by one, since the nested pointer already moves wlserver's touch 0.
|
||||
- `0111-wayland-pointer-warps-in-passthrough.patch` - this app: the nested pointer's motion goes to
|
||||
wlserver as touch 0, and in Passthrough (Big Picture's touch mode) a motion for a touch that is not
|
||||
down moves nothing - so in the app's touchpad mode the Steam client saw no hover and a click landed
|
||||
wherever the pointer had last been. The motion now always warps the real pointer as well
|
||||
(`bAlwaysWarpCursor`), which the other touch modes did already.
|
||||
|
||||
Sixteen more of Armada's patches are DRM/lease/HDR-on-KMS work for a native display, which this
|
||||
app's Wayland-hosted gamescope never reaches, or need a newer gamescope than the runtime has.
|
||||
@@ -0,0 +1,121 @@
|
||||
diff --git a/src/Backends/WaylandBackend.cpp b/src/Backends/WaylandBackend.cpp
|
||||
index d74268b..bf72267 100644
|
||||
--- a/src/Backends/WaylandBackend.cpp
|
||||
+++ b/src/Backends/WaylandBackend.cpp
|
||||
@@ -601,6 +601,17 @@ namespace gamescope
|
||||
void Wayland_Keyboard_RepeatInfo( wl_keyboard *pKeyboard, int32_t nRate, int32_t nDelay );
|
||||
static const wl_keyboard_listener s_KeyboardListener;
|
||||
|
||||
+ // Touch from the host compositor (DroidDeck): each finger goes to wlserver's touch path,
|
||||
+ // the one a Steam Deck's touchscreen drives, instead of being dropped.
|
||||
+ void Wayland_Touch_Down( wl_touch *pTouch, uint32_t uSerial, uint32_t uTime, wl_surface *pSurface, int32_t nId, wl_fixed_t fX, wl_fixed_t fY );
|
||||
+ void Wayland_Touch_Up( wl_touch *pTouch, uint32_t uSerial, uint32_t uTime, int32_t nId );
|
||||
+ void Wayland_Touch_Motion( wl_touch *pTouch, uint32_t uTime, int32_t nId, wl_fixed_t fX, wl_fixed_t fY );
|
||||
+ void Wayland_Touch_Frame( wl_touch *pTouch );
|
||||
+ void Wayland_Touch_Cancel( wl_touch *pTouch );
|
||||
+ static const wl_touch_listener s_TouchListener;
|
||||
+ bool TouchToOutput( wl_surface *pSurface, wl_fixed_t fX, wl_fixed_t fY, double *pflX, double *pflY );
|
||||
+ std::unordered_map<int32_t, wl_surface *> m_TouchSurfaces;
|
||||
+
|
||||
void Wayland_RelativePointer_RelativeMotion( zwp_relative_pointer_v1 *pRelativePointer, uint32_t uTimeHi, uint32_t uTimeLo, wl_fixed_t fDx, wl_fixed_t fDy, wl_fixed_t fDxUnaccel, wl_fixed_t fDyUnaccel );
|
||||
static const zwp_relative_pointer_v1_listener s_RelativePointerListener;
|
||||
};
|
||||
@@ -636,6 +647,16 @@ namespace gamescope
|
||||
.modifiers = WAYLAND_USERDATA_TO_THIS( CWaylandInputThread, Wayland_Keyboard_Modifiers ),
|
||||
.repeat_info = WAYLAND_USERDATA_TO_THIS( CWaylandInputThread, Wayland_Keyboard_RepeatInfo ),
|
||||
};
|
||||
+ const wl_touch_listener CWaylandInputThread::s_TouchListener =
|
||||
+ {
|
||||
+ .down = WAYLAND_USERDATA_TO_THIS( CWaylandInputThread, Wayland_Touch_Down ),
|
||||
+ .up = WAYLAND_USERDATA_TO_THIS( CWaylandInputThread, Wayland_Touch_Up ),
|
||||
+ .motion = WAYLAND_USERDATA_TO_THIS( CWaylandInputThread, Wayland_Touch_Motion ),
|
||||
+ .frame = WAYLAND_USERDATA_TO_THIS( CWaylandInputThread, Wayland_Touch_Frame ),
|
||||
+ .cancel = WAYLAND_USERDATA_TO_THIS( CWaylandInputThread, Wayland_Touch_Cancel ),
|
||||
+ .shape = WAYLAND_NULL(),
|
||||
+ .orientation = WAYLAND_NULL(),
|
||||
+ };
|
||||
const zwp_relative_pointer_v1_listener CWaylandInputThread::s_RelativePointerListener =
|
||||
{
|
||||
.relative_motion = WAYLAND_USERDATA_TO_THIS( CWaylandInputThread, Wayland_RelativePointer_RelativeMotion ),
|
||||
@@ -3067,6 +3088,81 @@ namespace gamescope
|
||||
wl_keyboard_add_listener( m_pKeyboard, &s_KeyboardListener, this );
|
||||
}
|
||||
}
|
||||
+
|
||||
+ if ( !!( uCapabilities & WL_SEAT_CAPABILITY_TOUCH ) != !!m_pTouch )
|
||||
+ {
|
||||
+ if ( m_pTouch )
|
||||
+ {
|
||||
+ wl_touch_release( m_pTouch );
|
||||
+ m_pTouch = nullptr;
|
||||
+ m_TouchSurfaces.clear();
|
||||
+ }
|
||||
+ else
|
||||
+ {
|
||||
+ m_pTouch = wl_seat_get_touch( m_pSeat );
|
||||
+ wl_touch_add_listener( m_pTouch, &s_TouchListener, this );
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ // Touch
|
||||
+
|
||||
+ bool CWaylandInputThread::TouchToOutput( wl_surface *pSurface, wl_fixed_t fX, wl_fixed_t fY, double *pflX, double *pflY )
|
||||
+ {
|
||||
+ if ( !pSurface || !IsGamescopeToplevel( pSurface ) )
|
||||
+ return false;
|
||||
+ CWaylandPlane *pPlane = (CWaylandPlane *)wl_surface_get_user_data( pSurface );
|
||||
+ if ( !pPlane )
|
||||
+ return false;
|
||||
+ auto oState = pPlane->GetCurrentState();
|
||||
+ if ( !oState || !g_nOutputWidth || !g_nOutputHeight )
|
||||
+ return false;
|
||||
+ uint32_t uScale = oState->uFractionalScale;
|
||||
+ *pflX = ( wl_fixed_to_double( fX ) * uScale / 120.0 + oState->nDestX ) / g_nOutputWidth;
|
||||
+ *pflY = ( wl_fixed_to_double( fY ) * uScale / 120.0 + oState->nDestY ) / g_nOutputHeight;
|
||||
+ return true;
|
||||
+ }
|
||||
+ // Touch ids are offset by one: the nested pointer already moves wlserver's touch 0.
|
||||
+ void CWaylandInputThread::Wayland_Touch_Down( wl_touch *pTouch, uint32_t uSerial, uint32_t uTime, wl_surface *pSurface, int32_t nId, wl_fixed_t fX, wl_fixed_t fY )
|
||||
+ {
|
||||
+ double flX, flY;
|
||||
+ if ( !TouchToOutput( pSurface, fX, fY, &flX, &flY ) )
|
||||
+ return;
|
||||
+ m_TouchSurfaces[ nId ] = pSurface;
|
||||
+ wlserver_lock();
|
||||
+ wlserver_touchdown( flX, flY, nId + 1, ++m_uFakeTimestamp );
|
||||
+ wlserver_unlock();
|
||||
+ }
|
||||
+ void CWaylandInputThread::Wayland_Touch_Up( wl_touch *pTouch, uint32_t uSerial, uint32_t uTime, int32_t nId )
|
||||
+ {
|
||||
+ if ( m_TouchSurfaces.erase( nId ) == 0 )
|
||||
+ return;
|
||||
+ wlserver_lock();
|
||||
+ wlserver_touchup( nId + 1, ++m_uFakeTimestamp );
|
||||
+ wlserver_unlock();
|
||||
+ }
|
||||
+ void CWaylandInputThread::Wayland_Touch_Motion( wl_touch *pTouch, uint32_t uTime, int32_t nId, wl_fixed_t fX, wl_fixed_t fY )
|
||||
+ {
|
||||
+ auto it = m_TouchSurfaces.find( nId );
|
||||
+ if ( it == m_TouchSurfaces.end() )
|
||||
+ return;
|
||||
+ double flX, flY;
|
||||
+ if ( !TouchToOutput( it->second, fX, fY, &flX, &flY ) )
|
||||
+ return;
|
||||
+ wlserver_lock();
|
||||
+ wlserver_touchmotion( flX, flY, nId + 1, ++m_uFakeTimestamp );
|
||||
+ wlserver_unlock();
|
||||
+ }
|
||||
+ void CWaylandInputThread::Wayland_Touch_Frame( wl_touch *pTouch )
|
||||
+ {
|
||||
+ }
|
||||
+ void CWaylandInputThread::Wayland_Touch_Cancel( wl_touch *pTouch )
|
||||
+ {
|
||||
+ wlserver_lock();
|
||||
+ for ( auto &[ nId, pSurface ] : m_TouchSurfaces )
|
||||
+ wlserver_touchup( nId + 1, ++m_uFakeTimestamp );
|
||||
+ wlserver_unlock();
|
||||
+ m_TouchSurfaces.clear();
|
||||
}
|
||||
|
||||
void CWaylandInputThread::Wayland_Seat_Name( wl_seat *pSeat, const char *pName )
|
||||
@@ -0,0 +1,16 @@
|
||||
diff --git a/src/Backends/WaylandBackend.cpp b/src/Backends/WaylandBackend.cpp
|
||||
index bf72267..c400978 100644
|
||||
--- a/src/Backends/WaylandBackend.cpp
|
||||
+++ b/src/Backends/WaylandBackend.cpp
|
||||
@@ -3223,7 +3223,10 @@ namespace gamescope
|
||||
double flY = ( wl_fixed_to_double( fSurfaceY ) * uScale / 120.0 + oState->nDestY ) / g_nOutputHeight;
|
||||
|
||||
wlserver_lock();
|
||||
- wlserver_touchmotion( flX, flY, 0, ++m_uFakeTimestamp );
|
||||
+ // Always move the real pointer too: in Passthrough (what Steam's Big Picture asks for) a
|
||||
+ // touch motion for an id that is not down moves nothing, so hover never reached the
|
||||
+ // client and a click landed wherever the pointer had last been.
|
||||
+ wlserver_touchmotion( flX, flY, 0, ++m_uFakeTimestamp, true );
|
||||
wlserver_unlock();
|
||||
}
|
||||
void CWaylandInputThread::Wayland_Pointer_Button( wl_pointer *pPointer, uint32_t uSerial, uint32_t uTime, uint32_t uButton, uint32_t uState )
|
||||
@@ -1,2 +1,2 @@
|
||||
GAMESCOPE_TAG=gamescope-3.16.29-p1
|
||||
GAMESCOPE_SHA256=522d1835d84e4a64a843e095bbe341f68b10f8467f38c0e72ae4ef20b675b873
|
||||
GAMESCOPE_TAG=gamescope-3.16.29-p3
|
||||
GAMESCOPE_SHA256=566e3b93931f37c30375d440d15ee76452892b842d2dc854cd3c06b926c8451b
|
||||
@@ -440,6 +440,42 @@ BUSCONF
|
||||
# falls through to the host, and the guest shell dies on libreadline before the game is reached.
|
||||
# FEX_ROOTFS names it outright and is read whatever the config situation.
|
||||
[ -d /usr/share/guestos/fex-mesa ] && export FEX_ROOTFS=/usr/share/guestos/fex-mesa
|
||||
# Decky is optional and session scoped: the app's marker opts in, and this supervisor is
|
||||
# reaped with the client session. Native ARM64 runs directly. A legacy x86_64 asset is used
|
||||
# only when its recorded architecture and this guest's existing FEX installation agree.
|
||||
decky_loader=$HOME/homebrew/services/PluginLoader
|
||||
if [ -f "$HOME/.droiddeck-decky-enabled" ] && [ -x "$decky_loader" ]; then
|
||||
decky_arch=$(cat "$HOME/homebrew/services/.droiddeck-decky-arch" 2>/dev/null || echo unknown)
|
||||
if [ "$decky_arch" = arm64 ] || { [ "$decky_arch" = x86_64 ] && [ -x /usr/bin/FEX ] && [ -n "${FEX_ROOTFS:-}" ]; }; then
|
||||
(
|
||||
decky_child=
|
||||
decky_stop() { [ -z "$decky_child" ] || kill "$decky_child" 2>/dev/null; [ -z "$decky_child" ] || wait "$decky_child" 2>/dev/null; exit 0; }
|
||||
trap decky_stop TERM INT HUP
|
||||
decky_exits=0
|
||||
while :; do
|
||||
if [ "$decky_arch" = x86_64 ]; then /usr/bin/FEX "$decky_loader" >>"$HOME/homebrew/services/PluginLoader.log" 2>&1 &
|
||||
else "$decky_loader" >>"$HOME/homebrew/services/PluginLoader.log" 2>&1 &
|
||||
fi
|
||||
decky_child=$!
|
||||
decky_status=0
|
||||
wait "$decky_child" || decky_status=$?
|
||||
decky_child=
|
||||
decky_exits=$((decky_exits + 1))
|
||||
if [ "$decky_exits" -ge 5 ]; then
|
||||
echo "== Decky: stopped after $decky_exits loader exits (last status $decky_status)"
|
||||
break
|
||||
fi
|
||||
decky_delay=$((decky_exits * 3))
|
||||
echo "== Decky: loader exited with status $decky_status; restart $((decky_exits + 1))/5 in ${decky_delay}s"
|
||||
sleep "$decky_delay"
|
||||
done
|
||||
) &
|
||||
decky_supervisor=$!
|
||||
echo "== Decky: session supervisor started ($decky_arch)"
|
||||
else
|
||||
echo "== Decky: installed loader architecture is unsupported by this guest; not starting"
|
||||
fi
|
||||
fi
|
||||
# The client exits 42 when it has updated itself and wants a fresh start (steam.sh does this).
|
||||
# BL_STEAM_UI=desktop = the client's desktop UI (a window, the library as a list), which the
|
||||
# desktop's launchers ask for; gamescope shows one window at a time and the game takes over.
|
||||
@@ -521,10 +557,20 @@ BUSCONF
|
||||
# script's parent, so reap_orphans - right under gamescope, where everything is the client's -
|
||||
# is not for here. The helpers this run started (the cores and redistributables beats, the
|
||||
# system bus and the NetworkManager stand-in) go with the client instead.
|
||||
if [ -n "${decky_supervisor:-}" ]; then
|
||||
kill "$decky_supervisor" 2>/dev/null || true
|
||||
wait "$decky_supervisor" 2>/dev/null || true
|
||||
echo "== Decky: session supervisor stopped"
|
||||
fi
|
||||
_bl_jobs=$(jobs -p)
|
||||
[ -n "$_bl_jobs" ] && kill $_bl_jobs 2>/dev/null
|
||||
echo "== steam: the client on the desktop has exited"
|
||||
else
|
||||
if [ -n "${decky_supervisor:-}" ]; then
|
||||
kill "$decky_supervisor" 2>/dev/null || true
|
||||
wait "$decky_supervisor" 2>/dev/null || true
|
||||
echo "== Decky: session supervisor stopped"
|
||||
fi
|
||||
reap_orphans
|
||||
fi
|
||||
exit "$rc"
|
||||
|
||||
Reference in new issue
Block a user