Compare commits

...
Author SHA1 Message Date
The412Banner 2d5358fa85 TEST ONLY: components-manager with the launch-time check removed from the Proton wrappers (crash bisect) 2026-09-26 00:20:43 -04:00
The412Banner d7ff92106a Remove a stray __pycache__ from the overlay scripts and ignore it 2026-09-26 00:20:41 -04:00
The412Banner 110aec0b6f Proton wrappers: drop the per-tool prefix for Steam's tool-0 runs - the client segfaulted right after its probe started (first in 25 sessions); keep the launch-time component check 2026-09-26 00:03:45 -04:00
The412Banner 06502bb49b Components: choices enforced at every game launch; Steam's tool-0 probes get their own prefix per Proton
Steam runs its graphics probe through every registered Proton at once after login, all in
compatdata/0. Prefix system32 links point into the Proton that set it up last, and the other
Protons' Wines, running outside Proton's pfx.lock, wrote their builtin DLLs through them into that
Proton's tree (device: GE's FEX DLLs became Proton Experimental's at session start).

- bannerlator-steam-compat: both wrappers (Valve's depot launcher and the third-party
  bannerlator-proton-wrap) give a tool-0 run STEAM_COMPAT_DATA_PATH=.../compatdata/0-<tool>, and
  before every other launch check the Proton's files against the app's desired.tsv and put back
  anything that differs (same Proton build only), logging to reapplied.log.
- ComponentsManager writes desired.tsv plus unpacked copies of the packages in use into the
  runtime after every change; the page and the drawer show 're-applied at launch'.
2026-09-25 23:54:10 -04:00
The412Banner c982d65675 Session drawer: tab spacing fits the room between LB and RB, so all four tabs show 2026-09-25 22:59:50 -04:00
The412Banner f4e606fb15 Components: the pad's LB / RB step through FEX, DXVK and VKD3D-Proton (wrapping), with LB / RB badges beside the tabs 2026-09-25 22:49:10 -04:00
The412Banner 9dbdb5e771 Components: fixed columns so details and Download buttons line up; focused button is a solid blue fill with a white outline 2026-09-25 22:37:07 -04:00
The412Banner fa12478fdd Components: compact layout - one toolbar line (title, Proton drop-down, component tabs, in use, (i), refresh) and one list of short rows for installed and available packages 2026-09-25 22:27:25 -04:00
The412Banner cbb9a168e6 Components: FEX, DXVK and VKD3D-Proton per Proton
A Components entry under Desktop opens a page built like the driver pages: a
Proton drop-down, FEX / DXVK / VKD3D-Proton tabs, what is in use, the installed
rows (each Proton build's original bundle and the stored packages; tap to
swap in, trash to delete), downloads from the Nightlies -Linux releases behind
the refresh button, and Import .wcp.

The session drawer gets a fourth tab, Components, for quick swaps between
what is installed. A swap into the Proton a running game uses waits until the
game closes and is applied then (drawer refresh, or the main screen on resume).

ComponentsManager keeps the same package format, originals layout and state as
the Decky Components Manager plugin and takes its data over once. A swap clears
only the component's own DLLs, so files a Proton keeps beside them stay.
2026-09-25 22:04:38 -04:00
The412Banner 73e2fc58a0 Merge pull request #24 from Droid-Deck/feat/drawer-tab-icons
Session drawer: QAM-style page tabs (Display, Controls, Settings)
2026-09-25 21:55:16 -04:00
The412Banner 500f4c69eb Session drawer: QAM-style page tabs - Display, Controls, Settings
The three page dots between LB and RB become icons standing in a line in page order, as in
Steam's Quick Access Menu. The selected one steps forward (full size, bright, a soft glow in the
accent), the others step back to half size and fade, and the line leans a little toward the
selection; tapping or picking a tab with the pad, and LB / RB, turn the page as before. The
first page's heading becomes Display to match its icon.
2026-09-25 18:11:04 -04:00
The412Banner 8cdedbe18a Merge pull request #22 from Droid-Deck/feat/steam-touch
Steam client: real touch in Big Picture, and a touchpad cursor that hovers and clicks
2026-09-25 17:06:19 -04:00
The412Banner 0f39d71f95 Merge remote-tracking branch 'org/main' into feat/steam-touch 2026-09-25 16:58:18 -04:00
The412Banner 1dc49b527b gamescope: stage 3.16.29-p3 (0111, pointer warps in Passthrough) 2026-09-25 16:38:50 -04:00
The412Banner cc3fec0768 gamescope: the nested pointer moves Steam's pointer in Passthrough too
In touchpad mode the cursor moved but Big Picture highlighted nothing and a tap-to-click
selected nothing: the nested pointer's motion reaches wlserver as touch 0, and in Passthrough
(the touch mode Big Picture sets) a motion for a touch that is not down moves nothing, so the X
pointer stayed where it was. 0111 passes bAlwaysWarpCursor, as the other touch modes already warp.
2026-09-25 16:34:22 -04:00
Kurt Himebauch 5270574e5a Add native Decky Loader support (#16)
* Add native Decky Loader management

* Enable Decky Steam frontend injection

* Disable unavailable stable Decky channel

* Decky: tie CEF debugging to session setting

* Decky: simplify Steam settings controls
2026-09-25 16:26:29 -04:00
The412Banner a8af973726 gamescope: stage 3.16.29-p2 (0110, touch from the host) 2026-09-25 16:06:31 -04:00
The412Banner ed1f8dd2b6 gamescope: take the host's touch in the nested Wayland backend
3.16.29's Wayland backend bound only wl_pointer and wl_keyboard; touch from DroidDeck's
compositor was never read, so Steam could not be touched. 0110 binds wl_touch in the input
thread and feeds each finger to wlserver_touchdown/touchmotion/touchup, the Steam Deck
touchscreen's path, so Big Picture's Passthrough touch mode gets real touches.
2026-09-25 15:55:26 -04:00
The412Banner e8aaae1343 Touch: a lost finger-up no longer blocks every later touch
On the pointer path (a client that takes no wl_touch - gamescope), touch_down counted the
fingers still down before clearing a sequence whose up had been lost, so the stale point left
any_active set and every later tap or swipe was dropped: in Direct touch mode Big Picture
ignored the screen while the pad worked. The count is taken after the clean-up, and the app
ends whatever the compositor still holds when a new gesture's first finger lands (Android has
ended every earlier gesture by then). The compositor logs once per gesture whether the window
got touch or the pointer.
2026-09-25 15:55:26 -04:00
Kurt Himebauch 036317f371 Build local APK with CI payload parity (#20) 2026-09-25 13:20:51 -04:00
The412Banner ce0da01163 Merge pull request #19 from Droid-Deck/fix/log-privacy
Session logs: scrub public addresses, device id, Steam account names, and every file
2026-09-25 12:53:30 -04:00
The412Banner 75ae619e21 Logs: scrub Steam account and persona names for every user
The client's UI log names the signed-in account on every login change ("Login:
OnLoginStateChange <account> ...", "OnLoginUsersChanged <account>"), ~200 lines on one device.
An email account was caught by the email rule; a plain account name was not, so a user signing in
with one had it in every shared log. Those two lines now lose the account, and every
AccountName and PersonaName in the client's loginusers.vdf is learned (with the device's own
addresses, learnFromRuntime) and replaced wherever it appears. The one-time pass over older
folders runs again for this (marker .scrubbed-2).
2026-09-25 11:51:37 -04:00
The412Banner 3efa5acfa6 Logs: no public addresses or device id, and every session file scrubbed
A privacy pass over the session folders found three leaks:
- the Steam client's IPv6 check logs "external address <the device's public IPv6>" about
  twenty times a session, and the redactor kept IP addresses on purpose, so steam/
  connection_log.txt carried it;
- network.txt listed the device's addresses (public IPv6 included) and the session's made-up
  MAC, which is seeded from ANDROID_ID and so stable per device;
- session.log, app.log, desktop.log, steam-desktop.log, crash.log were never scrubbed (clean on
  device, by luck).

The redactor now learns the device's own addresses from the session's link file (a global IPv6
as its /64, a public IPv4 exactly) and removes them wherever they appear, along with anything
logged as an "external address"; Valve's server addresses stay. network.txt gives every address
as its kind ("<public IPv6>") and masks the MAC. Every text file in a session folder goes
through the redactor when the session ends (killed sessions at the next app start), and again on
its way into the share-logs zip. Folders written before this are scrubbed once at app start.
2026-09-25 11:30:16 -04:00
The412Banner 9369dc3031 Merge pull request #18 from Droid-Deck/feat/melonds-preset
melonDS out of the box, cursor shapes and auto-hide, Steam desktop switching
2026-09-25 11:15:19 -04:00
21 changed files with 2365 additions and 117 deletions

No files matched your search

+1
View File
@@ -28,3 +28,4 @@ app/src/main/jniLibs/arm64-v8a/libproot-loader.so
!keystore/testkey.p12
droiddeck-release-key/
password.txt
__pycache__/
+12 -3
View File
@@ -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);
@@ -12,6 +12,7 @@ import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.Display
import android.view.KeyEvent
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
@@ -29,10 +30,13 @@ 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
import com.droiddeck.launcher.session.ProtonExtras
import com.droiddeck.launcher.session.ComponentsManager
import com.droiddeck.launcher.ui.ComponentsPage
import com.droiddeck.launcher.session.SessionLogShare
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.ui.ProtonPage
@@ -88,6 +92,23 @@ class MainActivity : ComponentActivity() {
private var steamDeckMode by mutableStateOf(false)
private var showCredits by mutableStateOf(false)
private var showProtons by mutableStateOf(false)
// Components page: FEX / DXVK / VKD3D-Proton per Proton (ComponentsManager).
private var showComponents by mutableStateOf(false)
private var compSnapshot by mutableStateOf<ComponentsManager.Snapshot?>(null)
private var compCatalog by mutableStateOf<List<ComponentsManager.CatalogItem>>(emptyList())
private var compCatalogAt by mutableStateOf(0L)
private var compProton by mutableStateOf<String?>(null)
private var compComp by mutableStateOf("dxvk")
private var compChecking by mutableStateOf(false)
private var compBusy by mutableStateOf<String?>(null)
private var compDownloads by mutableStateOf<Map<String, Int>>(emptyMap())
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)
@@ -132,6 +153,9 @@ class MainActivity : ComponentActivity() {
// The app's own picker (files/), once per kind of pick: the two driver lists validate
// differently, and the reason a zip is refused names the list it belongs in.
private val pickComponent = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedUri(r.data)?.let { importComponent(it) }
}
private val pickLinuxDriver = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedUri(r.data)?.let { importDriver(it, linux = true) }
}
@@ -239,6 +263,7 @@ class MainActivity : ComponentActivity() {
sm != null -> { { ModeSettingsHost(sm) } }
showPerformance -> { { PerformanceHost() } }
showProtons -> { { ProtonHost() } }
showComponents -> { { ComponentsHost() } }
showMapping -> { { MappingHost() } }
else -> null
}
@@ -252,7 +277,7 @@ class MainActivity : ComponentActivity() {
steamGames = steamGames, emulators = emulatorList, running = runningLabel,
frameGenEngine = FrameGen.engine(this), frameGenMultiplier = FrameGen.multiplier(this),
lsfgReady = LsfgNative.isInstalled(this),
pageKey = sm?.let { "settings:$it" } ?: if (showPerformance) "performance" else if (showProtons) "protons" else if (showMapping) "controller-mapping" else null,
pageKey = sm?.let { "settings:$it" } ?: if (showPerformance) "performance" else if (showProtons) "protons" else if (showComponents) "components" else if (showMapping) "controller-mapping" else null,
theme = theme,
isHomeApp = homeAppSelected,
homeScreenEnabled = homeScreenEnabled,
@@ -307,7 +332,8 @@ class MainActivity : ComponentActivity() {
frameGenLabel = FrameGen.label(this)
},
onProtons = { openProtons() },
onPerformance = { refreshCores(); showProtons = false; showMapping = false; showPerformance = true },
onComponents = { openComponents() },
onPerformance = { refreshCores(); showProtons = false; showComponents = false; showMapping = false; showPerformance = true },
onRoms = { showRoms = true },
onFiles = { startActivity(Intent(this, com.droiddeck.launcher.files.FileManagerActivity::class.java)) },
onLogs = {
@@ -328,7 +354,7 @@ class MainActivity : ComponentActivity() {
offline = OfflineMode.enabled(this)
},
onCredits = { showCredits = true },
onPageBack = { settingsMode = null; showPerformance = false; showProtons = false; showMapping = false },
onPageBack = { settingsMode = null; showPerformance = false; showProtons = false; showComponents = false; showMapping = false },
onTheme = { id -> SessionPrefs.setTheme(this, id); theme = id },
onHomeApp = { manageHomeApp() },
onHomeScreen = { on ->
@@ -352,7 +378,7 @@ class MainActivity : ComponentActivity() {
onAdaptiveSticks = { on -> ControllerPrefs.setAdaptiveSticks(this, on); refreshController() },
onEditLayout = { startActivity(Intent(this, ControllerEditorActivity::class.java)) },
onResetLayout = { ControllerPrefs.resetAllLayouts(this); refreshController() },
onMapping = { settingsMode = null; showPerformance = false; showProtons = false; showMapping = true },
onMapping = { settingsMode = null; showPerformance = false; showProtons = false; showComponents = false; showMapping = true },
onResetAll = { ControllerPrefs.resetAll(this); refreshController() },
),
),
@@ -403,16 +429,44 @@ class MainActivity : ComponentActivity() {
}
if (wanted.isNotEmpty()) requestPermissions(wanted.toTypedArray(), 1)
// A session folder left without its ending - the process was killed - gets it now.
if (!SessionState.running) Thread({ SessionArtifacts.finishAbandoned(this) }, "finish-abandoned").start()
if (!SessionState.running) Thread({
SessionArtifacts.finishAbandoned(this)
SessionArtifacts.scrubOlder(this)
}, "finish-abandoned").start()
}
/** On the Components page the pad's LB / RB step through FEX, DXVK and VKD3D-Proton, wrapping around. */
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
if (showComponents && (event.keyCode == KeyEvent.KEYCODE_BUTTON_L1 || event.keyCode == KeyEvent.KEYCODE_BUTTON_R1)) {
if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
val all = ComponentsManager.COMPONENTS
val step = if (event.keyCode == KeyEvent.KEYCODE_BUTTON_R1) 1 else all.size - 1
compComp = all[(all.indexOf(compComp).coerceAtLeast(0) + step) % all.size]
}
return true
}
return super.dispatchKeyEvent(event)
}
override fun onResume() {
super.onResume()
// Swaps queued while a game ran on that Proton go in once nothing uses it (usually the
// session has just ended). Cheap when nothing is queued.
if (!SessionState.running) Thread({
val applied = runCatching { ComponentsManager.applyQueued(this) }.getOrDefault(emptyList())
if (applied.isNotEmpty()) ui.post {
android.widget.Toast.makeText(this, "Applied: " + applied.joinToString(", "), android.widget.Toast.LENGTH_LONG).show()
if (showComponents) refreshComponents()
}
}, "components-queue").start()
oscMode = SessionPrefs.oscMode(this)
refreshController()
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()
@@ -478,11 +532,43 @@ class MainActivity : ComponentActivity() {
private fun openProtons() {
settingsMode = null
showPerformance = false
showComponents = false
showMapping = false
showProtons = true
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)
}
@@ -498,6 +584,106 @@ class MainActivity : ComponentActivity() {
)
}
private fun openComponents() {
settingsMode = null
showPerformance = false
showProtons = false
showMapping = false
showComponents = true
refreshComponents(snapshotFirst = true)
}
/** Reads the Protons (and on first open saves their originals) off the UI thread; the Nightlies list comes from its cache. */
private fun refreshComponents(snapshotFirst: Boolean = false) {
Thread({
if (snapshotFirst) runCatching { ComponentsManager.snapshotAll(this) }
runCatching { ComponentsManager.applyQueued(this) }
val snap = runCatching { ComponentsManager.snapshot(this) }.onFailure { Log.w(TAG, "components", it) }.getOrNull()
val cat = runCatching { ComponentsManager.catalog(this, false) }.getOrNull()
ui.post {
compSnapshot = snap ?: ComponentsManager.Snapshot(emptyList(), emptyList())
if (cat != null && cat.fetchedAt > 0) { compCatalog = cat.items; compCatalogAt = cat.fetchedAt }
if (compProton == null || snap?.protons?.none { it.proton.id == compProton } == true) compProton = snap?.protons?.firstOrNull()?.proton?.id
}
}, "components").start()
}
private fun componentAction(label: String, work: () -> String) {
if (compBusy != null) return
compBusy = label
Thread({
val message = runCatching(work).getOrElse { e -> "$label failed: ${e.message ?: e.javaClass.simpleName}" }
ui.post {
compBusy = null
android.widget.Toast.makeText(this, message, android.widget.Toast.LENGTH_LONG).show()
refreshComponents()
}
}, "components-action").start()
}
private fun refreshComponentCatalog() {
if (compChecking) return
compChecking = true
Thread({
val cat = runCatching { ComponentsManager.catalog(this, true) }.getOrNull()
ui.post {
compChecking = false
if (cat == null || cat.items.isEmpty()) android.widget.Toast.makeText(this, "The Nightlies could not be reached", android.widget.Toast.LENGTH_LONG).show()
else { compCatalog = cat.items; compCatalogAt = cat.fetchedAt }
}
}, "components-catalog").start()
}
private fun downloadComponent(item: ComponentsManager.CatalogItem) {
if (compDownloads.containsKey(item.file)) return
compDownloads = compDownloads + (item.file to -1)
Thread({
val message = runCatching {
val pkg = ComponentsManager.download(this, item) { pc -> ui.post { if (compDownloads.containsKey(item.file)) compDownloads = compDownloads + (item.file to pc) } }
"Stored ${pkg.version}"
}.getOrElse { e -> "Download failed: ${e.message ?: e.javaClass.simpleName}" }
ui.post {
compDownloads = compDownloads - item.file
android.widget.Toast.makeText(this, message, android.widget.Toast.LENGTH_SHORT).show()
refreshComponents()
}
}, "components-download").start()
}
private fun importComponent(uri: Uri) {
val name = displayNameOf(uri) ?: "imported.wcp"
componentAction("Importing") {
val tmp = File(cacheDir, "component-import.wcp")
contentResolver.openInputStream(uri)?.use { input -> tmp.outputStream().use { input.copyTo(it) } } ?: error("cannot read the file")
try { "Imported ${ComponentsManager.importPackage(this, tmp, name).version}" } finally { tmp.delete() }
}
}
@Composable
private fun ComponentsHost() {
ComponentsPage(
snapshot = compSnapshot,
catalog = compCatalog,
catalogAt = compCatalogAt,
protonId = compProton,
comp = compComp,
checking = compChecking,
busy = compBusy,
downloads = compDownloads,
onProton = { compProton = it },
onComp = { compComp = it },
onSwap = { file -> compProton?.let { pid -> componentAction("Swapping") { ComponentsManager.swap(this, pid, file) } } },
onRestore = { version -> compProton?.let { pid -> componentAction("Restoring") { ComponentsManager.restore(this, pid, compComp, version) } } },
onCancelQueued = { compProton?.let { pid -> componentAction("Cancelling") { ComponentsManager.cancelQueued(this, pid, compComp); "The waiting swap was cancelled." } } },
onDeletePackage = { file -> componentAction("Deleting") { ComponentsManager.deletePackage(this, file) } },
onDeleteOriginal = { version -> compProton?.let { pid -> componentAction("Deleting") { ComponentsManager.deleteOriginal(this, pid, compComp, version) } } },
onDownload = { downloadComponent(it) },
onRefresh = { refreshComponentCatalog() },
onImport = { pickComponent.launch(InAppFilePicker.buildIntent(this, WCP_EXT, "Choose a component package (-linux .wcp)")) },
onBack = { showComponents = false },
)
}
@Composable
private fun ProtonHost() {
ProtonPage(
@@ -620,6 +806,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 },
@@ -669,6 +859,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 },
),
)
@@ -724,6 +922,7 @@ class MainActivity : ComponentActivity() {
private fun openModeSettings(mode: String) {
showPerformance = false
showProtons = false
showComponents = false
showMapping = false
refreshDrivers()
resolutionCap = SessionPrefs.resolutionCap(this, mode)
@@ -746,6 +945,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
}
@@ -1046,5 +1250,6 @@ class MainActivity : ComponentActivity() {
private const val TAG = "MainActivity"
/** What the picker offers for a driver zip; some file apps label a zip as a plain stream. */
private val ZIP_EXT = listOf("zip")
private val WCP_EXT = listOf("wcp")
}
}
@@ -69,6 +69,8 @@ import com.droiddeck.launcher.ui.DroidDeckTheme
import com.droiddeck.launcher.wayland.CompositorHost
import com.droiddeck.launcher.wayland.WaylandCompositor
import java.io.File
import com.droiddeck.launcher.session.ComponentsManager
import com.droiddeck.launcher.ui.DRAWER_PAGES
import kotlin.math.abs
/**
@@ -140,6 +142,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
// Compose reads these; the activity writes them.
private var drawerOpen by mutableStateOf(false)
/** The drawer's Components tab: the Protons as last read (ComponentsManager). */
private var drawerComponents by mutableStateOf<ComponentsManager.Snapshot?>(null)
private var drawerPage by mutableIntStateOf(0)
private var drawerControllerActive by mutableStateOf(false)
private var backActionsInverted by mutableStateOf(false)
@@ -342,6 +346,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
onShareLogs = { drawerOpen = false; shareCurrentSessionLogs() },
onStop = { drawerOpen = false; SessionService.stop(this@SessionActivity); finish() },
onClose = { drawerOpen = false },
components = drawerComponents,
onComponentsRefresh = { refreshDrawerComponents() },
onComponentSwap = { pid, comp, value -> swapDrawerComponent(pid, comp, value) },
))
if (SessionState.suspended) SessionPausedOverlay {
SessionService.resume(this@SessionActivity)
@@ -424,6 +431,40 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (SessionState.running && SessionState.mode == SessionService.MODE_STEAM) sendSteamGuide()
}
/** Re-reads the Protons for the drawer's Components tab, first running swaps a closed game was holding back. */
private fun refreshDrawerComponents() {
Thread({
val applied = runCatching { ComponentsManager.applyQueued(this) }.getOrDefault(emptyList())
val snap = runCatching { ComponentsManager.snapshot(this) }.getOrNull()
uiHandler.post {
drawerComponents = snap
if (applied.isNotEmpty()) android.widget.Toast.makeText(this, "Applied: " + applied.joinToString(", "), android.widget.Toast.LENGTH_LONG).show()
}
}, "drawer-components").start()
}
private fun swapDrawerComponent(protonId: String, comp: String, value: String) {
Thread({
val message = runCatching {
val snap = ComponentsManager.snapshot(this)
val queued = snap.protons.firstOrNull { it.proton.id == protonId }?.components?.get(comp)?.queued
val active = snap.protons.firstOrNull { it.proton.id == protonId }?.components?.get(comp)?.activeFile
val build = snap.protons.firstOrNull { it.proton.id == protonId }?.proton?.version?.let(ComponentsManager::safeName)
val current = active ?: "orig:$build"
if (queued != null && value == current) {
// Picking what is already in place again cancels the swap waiting for the game.
ComponentsManager.cancelQueued(this, protonId, comp); "The waiting swap was cancelled."
} else if (value.startsWith("orig:")) ComponentsManager.restore(this, protonId, comp, value.removePrefix("orig:"))
else ComponentsManager.swap(this, protonId, value)
}.getOrElse { e -> "Swap failed: ${e.message ?: e.javaClass.simpleName}" }
uiHandler.post {
android.widget.Toast.makeText(this, message, android.widget.Toast.LENGTH_LONG).show()
refreshDrawerComponents()
}
}, "drawer-components-swap").start()
}
private fun shareCurrentSessionLogs() {
val folder = SessionPaths.current()
if (folder == null || !folder.isDirectory) {
@@ -854,7 +895,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (drawerOpen) {
if (fromController && (event.keyCode == KeyEvent.KEYCODE_BUTTON_L1 || event.keyCode == KeyEvent.KEYCODE_BUTTON_R1)) {
if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
drawerPage = (drawerPage + if (event.keyCode == KeyEvent.KEYCODE_BUTTON_R1) 1 else 2) % 3
drawerPage = (drawerPage + if (event.keyCode == KeyEvent.KEYCODE_BUTTON_R1) 1 else DRAWER_PAGES - 1) % DRAWER_PAGES
releaseDrawerDirection()
}
return true
@@ -1231,7 +1272,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)
@@ -12,9 +12,16 @@ package com.droiddeck.launcher.core
* client logs; the account-name registration is left out because this app never learns it.
*
* What is deliberately KEPT, because it is what makes a log worth reading: EResult codes, CM host
* names and IP addresses, timings, token expiry dates, app IDs, pids, connection-state changes and
* file paths. A SteamID is masked rather than deleted - first four and last four digits survive -
* so a reader can still tell two accounts' lines apart without the value identifying either.
* names and Valve's server addresses, timings, token expiry dates, app IDs, pids, connection-state
* changes and file paths. A SteamID is masked rather than deleted - first four and last four digits
* survive - so a reader can still tell two accounts' lines apart without the value identifying either.
*
* The device's OWN addresses are not kept: the client's IPv6 check logs "external address <ours>"
* about twenty times a session, and a public IPv6 address is the user's. [learnOwnAddresses] reads
* the link the session was given (etc/bannerlator-net) and every address on the same /64 - privacy
* addresses rotate inside it - or an own public IPv4 is replaced wherever it appears. Likewise every
* Steam account and persona name on the device ([learnAccounts]), for users who sign in with an
* account name rather than an email.
*/
object LogRedactor {
private val EMAIL = Regex("[A-Za-z0-9._%+\\-]+@[A-Za-z0-9.\\-]+\\.[A-Za-z]{2,}")
@@ -48,6 +55,145 @@ object LogRedactor {
private val LONG_TOKEN = Regex("[A-Za-z0-9_\\-]{88,}")
private val STEAMID64 = Regex("\\b(76561)(\\d{8})(\\d{4})\\b")
private val STEAMID3 = Regex("\\[U:1:(\\d+)]")
/** "external address 2607:..." / "external IP: 203.0.113.9" - the client stating ours. */
private val EXTERNAL_ADDR = Regex("(?i)(external\\s+(?:ip\\s+)?address\\s*[:=]?\\s*|external\\s+ip\\s*[:=]?\\s*)([0-9A-Fa-f:.]{7,})")
private val IPV4 = Regex("(?<![0-9.])(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})(?![0-9.])")
/** An IPv6 literal: "::" somewhere, or all eight groups (a clock's 10:03:05 is neither). */
private val IPV6 = Regex("(?<![0-9A-Za-z:])[0-9A-Fa-f]{0,4}(?::[0-9A-Fa-f]{0,4}){2,7}(?:%[A-Za-z0-9_.]+)?(?![0-9A-Za-z:])")
/**
* The account a Steam UI login line names ("Login: OnLoginStateChange <account> 2 1 0 0"): a
* user who signs in with a plain account name, not an email, had it in every log. The single
* space then a non-space keeps "OnLoginStateChange 0 1 0 0" (no account yet) as it is.
*/
private val LOGIN_STATE = Regex("(OnLoginStateChange )(\\S+)")
private val LOGIN_USERS = Regex("(OnLoginUsersChanged )(\\S.*)$")
/** The device's Steam accounts and persona names as patterns (see [learnAccounts]). */
@Volatile
private var accounts: List<Regex> = emptyList()
/**
* Learns every Steam account on the device from the client's loginusers.vdf - its AccountName
* and PersonaName - so they are replaced wherever a log mentions them, whoever the user is.
* Names under three characters are skipped: blanking every "a" would ruin a log.
*/
fun learnAccounts(loginUsers: java.io.File) {
val found = ArrayList<Regex>()
try {
if (loginUsers.isFile) {
val kv = Regex("\"(AccountName|PersonaName)\"\\s+\"([^\"]*)\"")
kv.findAll(loginUsers.readText()).map { it.groupValues[2].trim() }.filter { it.length >= 3 }.distinct().forEach { name ->
found += Regex("(?i)(?<![A-Za-z0-9_])" + Regex.escape(name) + "(?![A-Za-z0-9_])")
}
}
} catch (e: Exception) {
return
}
accounts = found
}
/** Everything the session's runtime can tell about whose logs these are: addresses and accounts. */
fun learnFromRuntime(root: java.io.File) {
learnOwnAddresses(java.io.File(root, "etc/bannerlator-net"))
learnAccounts(java.io.File(root, "root/.local/share/Steam/config/loginusers.vdf"))
}
/** This device's public addresses as patterns (see [learnOwnAddresses]); empty until learned. */
@Volatile
private var own: List<Regex> = emptyList()
/**
* Learns the device's addresses from the link file the app writes for the session
* (`addr <address> <prefix>` lines). Global IPv6 become their /64, public IPv4 stay exact;
* private, link-local and loopback addresses identify nobody and are left out.
*/
fun learnOwnAddresses(linkFile: java.io.File) {
val found = ArrayList<Regex>()
try {
if (linkFile.isFile) linkFile.forEachLine { line ->
val addr = line.trim().takeIf { it.startsWith("addr ") }?.split(Regex("\\s+"))?.getOrNull(1) ?: return@forEachLine
when (kind(addr)) {
"public IPv6" -> {
val groups = expand6(addr)?.take(4) ?: return@forEachLine
// Any spelling of an address in this /64: leading zeros dropped, :: anywhere after.
val prefix = groups.joinToString(":") { g -> "0{0,3}" + Regex.escape(g.trimStart('0').ifEmpty { "0" }) }
found += Regex("(?i)(?<![0-9A-Fa-f:])$prefix(?::[0-9A-Fa-f]{0,4}){1,4}(?:%[A-Za-z0-9_.]+)?")
}
"public IPv4" -> found += Regex("(?<![0-9.])" + Regex.escape(addr) + "(?![0-9.])")
}
}
} catch (e: Exception) {
return
}
own = found
}
/** "public IPv6", "private IPv4", "link-local IPv6", ... for an address literal; null if it is not one. */
fun kind(address: String): String? {
val a = address.substringBefore('%')
IPV4.matchEntire(a)?.let { m ->
val o = m.groupValues.drop(1).map { it.toInt() }
if (o.any { it > 255 }) return null
return when {
o[0] == 127 -> "loopback IPv4"
o[0] == 10 || (o[0] == 172 && o[1] in 16..31) || (o[0] == 192 && o[1] == 168) -> "private IPv4"
o[0] == 169 && o[1] == 254 -> "link-local IPv4"
o[0] == 100 && o[1] in 64..127 -> "carrier-NAT IPv4"
else -> "public IPv4"
}
}
val g = expand6(a) ?: return null
val first = g[0].toInt(16)
return when {
g.all { it.toInt(16) == 0 } || (g.take(7).all { it.toInt(16) == 0 } && g[7].toInt(16) == 1) -> "loopback IPv6"
first and 0xffc0 == 0xfe80 -> "link-local IPv6"
first and 0xfe00 == 0xfc00 -> "private IPv6"
else -> "public IPv6"
}
}
/** The eight groups of an IPv6 literal, or null. */
private fun expand6(address: String): List<String>? {
val a = address.substringBefore('%')
if (!a.contains(':') || a.count { it == ':' } > 7 || a.contains(":::")) return null
if (!a.all { it.isDigit() || it in 'a'..'f' || it in 'A'..'F' || it == ':' }) return null
val parts = if (a.contains("::")) {
val (head, tail) = a.split("::", limit = 2)
val h = if (head.isEmpty()) emptyList() else head.split(':')
val t = if (tail.isEmpty()) emptyList() else tail.split(':')
if (h.size + t.size > 7) return null
h + List(8 - h.size - t.size) { "0" } + t
} else a.split(':')
if (parts.size != 8 || parts.any { it.isEmpty() || it.length > 4 }) return null
return parts.map { it.lowercase() }
}
/**
* Every IP address in [text] replaced by what kind it is ("<public IPv6>"), for reports whose
* point is the shape of the network, not its numbers.
*/
fun describeAddresses(text: String): String {
var out = IPV6.replace(text) { m -> kind(m.value)?.let { "<$it>" } ?: m.value }
out = IPV4.replace(out) { m -> kind(m.value)?.let { "<$it>" } ?: m.value }
return out
}
/** A file worth scrubbing: text (no NUL in its first 4 KB) and not huge. */
fun isText(file: java.io.File): Boolean = try {
file.isFile && file.length() < 64L * 1024 * 1024 && file.inputStream().use { input ->
val buf = ByteArray(4096)
val n = input.read(buf)
n <= 0 || (0 until n).none { buf[it].toInt() == 0 }
}
} catch (e: Exception) {
false
}
/** [src]'s lines, scrubbed, to [out]. */
fun scrubTo(src: java.io.File, out: java.io.Writer) {
src.forEachLine { line -> out.write(redact(line)); out.write("\n") }
}
/** [line] with every credential shape replaced. Null- and exception-safe by construction. */
fun redact(line: String): String {
@@ -66,6 +212,11 @@ object LogRedactor {
val id = m.groupValues[1]
"[U:1:${if (id.length > 4) "*".repeat(id.length - 4) + id.takeLast(4) else id}]"
}
out = EXTERNAL_ADDR.replace(out) { "${it.groupValues[1]}<redacted:ip>" }
for (r in own) out = r.replace(out, "<redacted:ip>")
out = LOGIN_STATE.replace(out) { "${it.groupValues[1]}<redacted:account>" }
out = LOGIN_USERS.replace(out) { "${it.groupValues[1]}<redacted:account>" }
for (r in accounts) out = r.replace(out, "<redacted:account>")
out = EMAIL.replace(out, "<redacted:email>")
out = RESIDUAL.replace(out) { "${it.groupValues[1]}=<redacted:token>" }
out = LONG_TOKEN.replace(out, "<redacted:token>")
@@ -27,7 +27,12 @@ object NetworkReport {
fun write(context: Context, target: File) {
try {
target.writeText(build(context))
// Addresses as their kind ("<public IPv6>"), never the numbers: a public address is the
// user's, and this file goes into shared session logs. The session's made-up hardware
// address is stable per device (seeded from ANDROID_ID), so it is masked too.
val text = LogRedactor.describeAddresses(build(context))
.replace(Regex("(?m)^(\\s*mac )\\S+"), "$1<masked>")
target.writeText(text)
} catch (e: Exception) {
Log.w(TAG, "could not write $target", e)
}
@@ -36,7 +41,7 @@ object NetworkReport {
private fun build(context: Context): String = buildString {
append("Network as the session starts\n")
append("=============================\n")
append("The SSID is deliberately not recorded. Transport, address families and DNS are.\n\n")
append("The SSID is deliberately not recorded, and addresses appear as their kind, not their numbers.\n\n")
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
val network = cm?.activeNetwork
if (cm == null || network == null) {
@@ -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)
}
@@ -0,0 +1,654 @@
package com.droiddeck.launcher.session
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.core.Downloader
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.github.luben.zstd.ZstdInputStream
import com.github.luben.zstd.ZstdOutputStream
import org.apache.commons.compress.archivers.tar.TarArchiveEntry
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream
import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream
import org.json.JSONArray
import org.json.JSONObject
import java.io.BufferedInputStream
import java.io.BufferedOutputStream
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.security.MessageDigest
/**
* FEX, DXVK and VKD3D-Proton per Proton, for the Linux Steam client.
*
* Every Proton the client runs games with keeps its own copy of these three inside its tree. This
* lists them, stores packages (.wcp: tar + zstd with a profile.json) downloaded from the Nightlies
* "-Linux" releases or imported, swaps one into a Proton, keeps the Proton's shipped files as
* "original bundles" - one per Proton build, so a Steam update adds a new one instead of replacing
* the last - restores those, and deletes stored packages.
*
* Where a Proton reads each component (its own `proton` script, ARM64 host):
* DXVK files/lib/wine/dxvk/{aarch64,i386}-windows -> the prefix's system32 / syswow64
* VKD3D files/lib/wine/vkd3d-proton/{aarch64,i386}-windows -> the same, on every launch
* FEX files/lib/wine/aarch64-windows/lib{arm64ec,wow64}fex.dll
* files/lib/wine/aarch64-unix/lib{arm64ec,wow64}fex.so (FEX 2607 and newer)
* Proton copies DXVK and VKD3D into the prefix on every normal launch, so a swap applies the next
* time a game starts. A swap into a Proton a running game is using waits until that game exits.
*
* Same formats and data layout as the Decky "Components Manager" plugin, whose data is taken over
* once on first use.
*/
object ComponentsManager {
private const val TAG = "Components"
private const val NIGHTLIES = "The412Banner/Nightlies"
/** Release tag -> component. Only the Linux repacks: the Android releases carry bionic unixlibs. */
private val RELEASES = linkedMapOf(
"FexCore-Linux" to "fex",
"Dxvk-Linux" to "dxvk",
"Dxvk-arm64ec-Linux" to "dxvk",
"Dxvk-gplasync-Linux" to "dxvk",
"Vkd3d-proton-Linux" to "vkd3d",
)
private val TYPES = mapOf("FEXCore-Linux" to "fex", "DXVK-Linux" to "dxvk", "VKD3D-Linux" to "vkd3d")
private val TYPE_OF = mapOf("fex" to "FEXCore-Linux", "dxvk" to "DXVK-Linux", "vkd3d" to "VKD3D-Linux")
val COMPONENTS = listOf("fex", "dxvk", "vkd3d")
val LABEL = mapOf("fex" to "FEX", "dxvk" to "DXVK", "vkd3d" to "VKD3D-Proton")
private const val WINE = "files/lib/wine"
private val FEX_FILES = listOf(
"$WINE/aarch64-windows/libarm64ecfex.dll",
"$WINE/aarch64-windows/libwow64fex.dll",
"$WINE/aarch64-unix/libarm64ecfex.so",
"$WINE/aarch64-unix/libwow64fex.so",
)
private val COMP_DIR = mapOf("dxvk" to "$WINE/dxvk", "vkd3d" to "$WINE/vkd3d-proton")
/** The folders a Proton reads on ARM64. */
private val PE_ARCHES = listOf("aarch64-windows", "i386-windows")
/**
* The DLLs that ARE the component. A swap clears exactly these before unpacking, so no file of
* the previous version is left mixed in; anything else a Proton keeps beside them - GE's
* openvr_api_dxvk.dll, which its launcher copies into every prefix and aborts without - stays.
*/
private val COMP_DLLS = mapOf(
"dxvk" to setOf("d3d8.dll", "d3d9.dll", "d3d10.dll", "d3d10_1.dll", "d3d10core.dll", "d3d11.dll", "dxgi.dll"),
"vkd3d" to setOf("d3d12.dll", "d3d12core.dll"),
)
private const val PLUGIN_DATA = "root/homebrew/data/decky-components-manager"
data class Proton(val id: String, val name: String, val dir: File, val guestPath: String, val version: String, val valve: Boolean)
data class Component(val detected: String, val detail: String, val inUse: String, val activeFile: String?, val queued: String?)
data class Original(val comp: String, val protonVersion: String, val label: String, val size: Long)
data class Package(val file: String, val comp: String, val version: String, val description: String, val size: Long)
data class CatalogItem(val file: String, val comp: String, val release: String, val url: String, val size: Long, val digest: String)
data class ProtonView(
val proton: Proton, val components: Map<String, Component>, val originals: List<Original>, val inUseByGame: Boolean,
/** When a launch last had to put this Proton's chosen files back (epoch seconds), or 0. */
val reappliedAt: Long = 0,
)
data class Snapshot(val protons: List<ProtonView>, val packages: List<Package>)
data class Catalog(val items: List<CatalogItem>, val fetchedAt: Long)
private val lock = Any()
// ------------------------------------------------------------------ paths
private fun root(context: Context) = LinuxRuntime.rootDir(context)
private fun steam(context: Context) = File(root(context), "root/.local/share/Steam")
private fun host(context: Context, guest: String) = File(root(context), guest.removePrefix("/"))
private fun dataDir(context: Context) = File(context.filesDir, "components").apply { mkdirs() }
private fun packagesDir(context: Context) = File(dataDir(context), "packages").apply { mkdirs() }
private fun originalsDir(context: Context) = File(dataDir(context), "originals").apply { mkdirs() }
private fun stateFile(context: Context) = File(dataDir(context), "state.json")
private fun catalogFile(context: Context) = File(dataDir(context), "catalog.json")
/**
* Inside the Linux runtime, read by the Proton launch wrappers (bannerlator-steam-compat's
* bl_components) before every game launch: desired.tsv + an unpacked copy of each package in use.
*/
private const val LAUNCH_DIR = "root/.local/share/bannerlator-components"
private fun launchDir(context: Context) = File(root(context), LAUNCH_DIR)
fun safeName(s: String): String = s.replace(Regex("[^A-Za-z0-9._+-]+"), "_").trim('_').ifEmpty { "unnamed" }
// ------------------------------------------------------------------ state
private fun loadState(context: Context): JSONObject =
runCatching { JSONObject(stateFile(context).readText()) }.getOrElse { JSONObject() }.apply {
if (!has("active")) put("active", JSONObject())
if (!has("queued")) put("queued", JSONObject())
}
private fun saveState(context: Context, state: JSONObject) {
val f = stateFile(context)
val tmp = File(f.parentFile, f.name + ".tmp")
tmp.writeText(state.toString(2))
tmp.renameTo(f)
// Every change of choice reaches the launch wrappers at once.
runCatching { syncLaunchState(context, state) }.onFailure { Log.w(TAG, "launch state", it) }
}
private fun JSONObject.sub(key: String): JSONObject = optJSONObject(key) ?: JSONObject().also { put(key, it) }
/** Takes over the Decky plugin's packages, originals and swap records, once. */
private fun migratePlugin(context: Context) {
val marker = File(dataDir(context), ".plugin-imported")
if (marker.isFile) return
val plugin = File(root(context), PLUGIN_DATA)
if (plugin.isDirectory) {
runCatching {
File(plugin, "packages").listFiles()?.forEach { f ->
if (f.isFile && f.name.endsWith(".wcp")) {
val dst = File(packagesDir(context), f.name)
if (!dst.exists()) f.copyTo(dst)
}
}
val originals = File(plugin, "originals")
if (originals.isDirectory) originals.copyRecursively(originalsDir(context), overwrite = false)
val ps = File(plugin, "state.json")
if (ps.isFile && !stateFile(context).isFile) ps.copyTo(stateFile(context))
Log.i(TAG, "took over the Decky plugin's data from $plugin")
}.onFailure { Log.w(TAG, "plugin data import", it) }
}
marker.writeText("1\n")
}
// ------------------------------------------------------------------ Protons
private fun libraryRoots(context: Context): List<File> {
val roots = mutableListOf(steam(context))
val vdf = File(steam(context), "steamapps/libraryfolders.vdf")
runCatching {
Regex("\"path\"\\s+\"([^\"]+)\"").findAll(vdf.readText()).forEach {
val h = host(context, it.groupValues[1])
if (roots.none { r -> r.canonicalPath == h.canonicalPath }) roots.add(h)
}
}
return roots
}
private fun displayName(dir: File): String = runCatching {
Regex("\"display_name\"\\s+\"([^\"]+)\"").find(File(dir, "compatibilitytool.vdf").readText())?.groupValues?.get(1)
}.getOrNull() ?: dir.name
private fun protonVersion(dir: File): String = runCatching {
val parts = File(dir, "version").useLines { it.firstOrNull() ?: "" }.trim().split(Regex("\\s+"))
if (parts.size > 1) parts[1] else parts.firstOrNull()?.ifEmpty { null } ?: "unknown"
}.getOrDefault("unknown")
/** Every Proton tree with a Wine inside. Launch wrappers pointing at another Proton are left out. */
fun protons(context: Context): List<Proton> {
val found = LinkedHashMap<String, Proton>()
val bases = listOf(File(steam(context), "compatibilitytools.d") to false) +
libraryRoots(context).map { File(it, "steamapps/common") to true }
// Not canonical: /data/user/0 is a link to /data/data on many devices, and the guest path is
// what is left after removing the rootfs prefix exactly as the paths were built.
val rootPath = root(context).absolutePath.trimEnd('/')
for ((base, valve) in bases) {
val entries = base.listFiles()?.sortedBy { it.name } ?: continue
for (dir in entries) {
if (!File(dir, WINE).isDirectory || !File(dir, "proton").isFile) continue
val real = runCatching { dir.canonicalPath }.getOrDefault(dir.path)
if (found.containsKey(real)) continue
val guest = if (dir.absolutePath.startsWith(rootPath)) dir.absolutePath.removePrefix(rootPath) else dir.absolutePath
found[real] = Proton(safeName(dir.name), displayName(dir), dir, guest, protonVersion(dir), valve)
}
}
return found.values.toList()
}
private fun proton(context: Context, id: String) = protons(context).firstOrNull { it.id == id }
?: throw IllegalStateException("No Proton with id $id")
/** Is a game (or anything) running on this Proton right now? Read from the processes' command lines. */
fun inUse(proton: Proton): Boolean {
val needle = proton.guestPath.trimEnd('/') + "/"
val procs = File("/proc").listFiles() ?: return false
for (p in procs) {
if (!p.name.all(Char::isDigit)) continue
val cmd = runCatching { File(p, "cmdline").readBytes() }.getOrNull() ?: continue
if (cmd.isEmpty()) continue
val text = String(cmd).replace('\u0000', ' ')
if (text.contains(needle + "proton ") || text.contains(needle + "files/bin")) return true
}
return false
}
// ------------------------------------------------------------------ component files
private fun readVersionFile(f: File): String = runCatching {
val text = f.readText().trim()
Regex("\\(([^)]+)\\)").find(text)?.groupValues?.get(1) ?: text.split(Regex("\\s+")).last()
}.getOrDefault("")
private fun fexVersion(dir: File): String {
val dll = File(dir, FEX_FILES[0])
if (!dll.isFile) return ""
val text = runCatching { String(dll.readBytes(), Charsets.ISO_8859_1) }.getOrDefault("")
return Regex("FEX-\\d{4}(?:\\.\\d+)?(?:-\\d+-g[0-9a-f]{6,})?").findAll(text).map { it.value }.maxByOrNull { it.length }
?: "FEX (build without a version name)"
}
private fun compFiles(dir: File, comp: String): List<String> {
if (comp == "fex") return FEX_FILES.filter { File(dir, it).isFile }
val base = File(dir, COMP_DIR.getValue(comp))
return base.walkTopDown().filter { it.isFile }.map { it.relativeTo(dir).path }.sorted().toList()
}
private fun owned(comp: String, rel: String) = comp == "fex" || PE_ARCHES.any { "/$it/" in rel }
private fun sha256(f: File): String {
val md = MessageDigest.getInstance("SHA-256")
FileInputStream(f).use { input ->
val buf = ByteArray(1 shl 16)
while (true) { val n = input.read(buf); if (n < 0) break; md.update(buf, 0, n) }
}
return md.digest().joinToString("") { "%02x".format(it.toInt() and 255) }
}
private fun fingerprint(dir: File, comp: String): Map<String, String> =
compFiles(dir, comp).filter { owned(comp, it) }.associateWith { sha256(File(dir, it)) }
// ------------------------------------------------------------------ packages
private fun normalize(name: String) = name.removePrefix("./").trimStart('/')
private inline fun <T> readWcp(f: File, block: (TarArchiveInputStream) -> T): T =
TarArchiveInputStream(ZstdInputStream(BufferedInputStream(FileInputStream(f), 1 shl 16))).use(block)
private fun readProfile(f: File): JSONObject = readWcp(f) { tar ->
while (true) {
val e = tar.nextTarEntry ?: break
if (!e.isDirectory && normalize(e.name) == "profile.json") return@readWcp JSONObject(tar.readBytes().decodeToString())
}
throw IllegalArgumentException("no profile.json in ${f.name}")
}
private fun packageInfo(f: File): Package {
val prof = readProfile(f)
val comp = TYPES[prof.optString("type")] ?: throw IllegalArgumentException("${f.name} is not a Linux component package (type ${prof.optString("type")})")
return Package(f.name, comp, prof.optString("versionName", f.nameWithoutExtension), prof.optString("description"), f.length())
}
fun packages(context: Context): List<Package> = packagesDir(context).listFiles()
?.filter { it.isFile && it.name.endsWith(".wcp") }?.sortedBy { it.name }
?.mapNotNull { f -> runCatching { packageInfo(f) }.onFailure { Log.w(TAG, "skipping ${f.name}", it) }.getOrNull() }
?: emptyList()
/** Unpacks a package's files/ into a Proton; for DXVK/VKD3D the component's own DLLs are replaced as a set. */
private fun installFiles(context: Context, dir: File, comp: String, wcp: File): Map<String, String> {
val staged = File(dataDir(context), "staging-${System.nanoTime()}")
try {
readWcp(wcp) { tar ->
while (true) {
val e = tar.nextTarEntry ?: break
val rel = normalize(e.name)
if (e.isDirectory || !rel.startsWith("files/")) continue
require(rel.split('/').none { it == ".." }) { "unsafe path $rel" }
val out = File(staged, rel)
out.parentFile?.mkdirs()
FileOutputStream(out).use { tar.copyTo(it) }
}
}
require(File(staged, "files").isDirectory) { "${wcp.name} has no files/" }
COMP_DIR[comp]?.let { compDir ->
for (arch in PE_ARCHES) {
val target = File(dir, "$compDir/$arch")
if (!File(staged, "$compDir/$arch").isDirectory || !target.isDirectory) continue
target.listFiles()?.filter { it.name.lowercase() in COMP_DLLS.getValue(comp) }?.forEach { it.delete() }
}
}
val hashes = LinkedHashMap<String, String>()
File(staged, "files").walkTopDown().filter { it.isFile }.forEach { src ->
val rel = src.relativeTo(staged).path
val dst = File(dir, rel)
dst.parentFile?.mkdirs()
val tmp = File(dst.parentFile, dst.name + ".dcm-new")
src.copyTo(tmp, overwrite = true)
if (rel.endsWith(".so")) tmp.setExecutable(true, false)
tmp.setReadable(true, false)
if (!tmp.renameTo(dst)) { dst.delete(); check(tmp.renameTo(dst)) { "could not place $rel" } }
hashes[rel] = sha256(dst)
}
return hashes
} finally {
staged.deleteRecursively()
}
}
private fun writeWcp(dest: File, profile: JSONObject, dir: File, rels: List<String>) {
val tmp = File(dest.parentFile, dest.name + ".part")
TarArchiveOutputStream(ZstdOutputStream(BufferedOutputStream(FileOutputStream(tmp), 1 shl 16), 9)).use { tar ->
tar.setLongFileMode(TarArchiveOutputStream.LONGFILE_POSIX)
val bytes = profile.toString(2).toByteArray()
tar.putArchiveEntry(TarArchiveEntry("./profile.json").apply { size = bytes.size.toLong() })
tar.write(bytes); tar.closeArchiveEntry()
for (rel in rels) {
val f = File(dir, rel)
tar.putArchiveEntry(TarArchiveEntry(f, "./$rel"))
FileInputStream(f).use { it.copyTo(tar) }
tar.closeArchiveEntry()
}
}
tmp.renameTo(dest)
}
// ------------------------------------------------------------------ originals
private fun originalFile(context: Context, p: Proton, comp: String) =
File(originalsDir(context), "${p.id}/${safeName(p.version)}/$comp.wcp").apply { parentFile?.mkdirs() }
fun originals(context: Context, protonId: String): List<Original> {
val base = File(originalsDir(context), protonId)
val versions = base.listFiles()?.filter { it.isDirectory }?.sortedByDescending { it.lastModified() } ?: return emptyList()
return versions.flatMap { v ->
COMPONENTS.mapNotNull { comp ->
val f = File(v, "$comp.wcp")
if (!f.isFile) null else Original(comp, v.name, runCatching { readProfile(f).optString("description") }.getOrDefault(""), f.length())
}
}
}
/**
* Saves a Proton's shipped files for this component before they are first replaced in this
* Proton build. A build already swapped into is not saved again: its files are ours.
*/
private fun ensureOriginal(context: Context, p: Proton, comp: String, state: JSONObject) {
val dest = originalFile(context, p, comp)
if (dest.isFile) return
val active = state.sub("active").optJSONObject(p.id)?.optJSONObject(comp)
if (active != null && active.optString("protonVersion") == p.version) return
val rels = compFiles(p.dir, comp)
if (rels.isEmpty()) return
val current = if (comp == "fex") fexVersion(p.dir) else readVersionFile(File(p.dir, "${COMP_DIR.getValue(comp)}/version"))
val profile = JSONObject()
.put("type", TYPE_OF.getValue(comp))
.put("versionName", "original-${p.version}")
.put("versionCode", 0)
.put("description", "Original · ${p.name} · ${p.version} · ${LABEL.getValue(comp)} $current".trim())
.put("files", JSONArray(rels.map { JSONObject().put("source", it).put("target", "\${proton}/$it") }))
writeWcp(dest, profile, p.dir, rels)
Log.i(TAG, "saved original $comp of ${p.name} ${p.version}")
}
/** Saves the originals of every Proton build not seen yet (first open, and after a Steam update). */
fun snapshotAll(context: Context) = synchronized(lock) {
migratePlugin(context)
val state = loadState(context)
for (p in protons(context)) for (comp in COMPONENTS) {
runCatching { ensureOriginal(context, p, comp, state) }.onFailure { Log.w(TAG, "original $comp of ${p.name}", it) }
}
runCatching { syncLaunchState(context, state) }.onFailure { Log.w(TAG, "launch state", it) }
}
// ------------------------------------------------------------------ view
fun snapshot(context: Context): Snapshot = synchronized(lock) {
migratePlugin(context)
val state = loadState(context)
val reapplied = HashMap<String, Long>()
runCatching {
File(launchDir(context), "reapplied.log").forEachLine { line ->
val parts = line.split('\t')
if (parts.size >= 2) parts[0].toLongOrNull()?.let { reapplied[parts[1]] = maxOf(reapplied[parts[1]] ?: 0, it) }
}
}
val views = protons(context).map { p ->
val comps = COMPONENTS.associateWith { comp ->
val detected = if (comp == "fex") fexVersion(p.dir) else readVersionFile(File(p.dir, "${COMP_DIR.getValue(comp)}/version"))
val detail = if (comp == "fex") (if (File(p.dir, FEX_FILES[2]).isFile) "with Linux helpers" else "DLLs only") else ""
val active = state.sub("active").optJSONObject(p.id)?.optJSONObject(comp)
val inUseLabel = when {
active == null -> "Original"
active.optString("protonVersion") != p.version -> "Original (Proton updated since the last swap)"
else -> {
val want = active.optJSONObject("files") ?: JSONObject()
val fp = fingerprint(p.dir, comp)
if (want.keys().asSequence().all { fp[it] == want.optString(it) }) active.optString("label", active.optString("file"))
else "Changed outside Components"
}
}
val q = state.sub("queued").optJSONObject(p.id)?.optJSONObject(comp)
Component(
detected.ifEmpty { "not present" }, detail, inUseLabel,
active?.optString("file")?.takeIf { active.optString("protonVersion") == p.version },
q?.optString("label"),
)
}
ProtonView(p, comps, originals(context, p.id), inUse(p), reapplied[p.dir.name] ?: 0)
}
Snapshot(views, packages(context))
}
// ------------------------------------------------------------------ launch-time enforcement
/**
* Writes what each Proton should be using for the launch wrappers: desired.tsv, one line per
* file (tool dir, Proton build, component, file, sha256, source copy), and an unpacked copy of
* every package or older-build original in use to copy from. Rebuilt whole after every change, so
* a restored or deleted choice simply stops being enforced. The Proton's own current-build
* originals are not listed: the wrappers also give Steam's probe runs their own prefix, which is
* what overwrote files in the first place.
*/
private fun syncLaunchState(context: Context, state: JSONObject) {
val dir = launchDir(context)
val store = File(dir, "store").apply { mkdirs() }
val all = protons(context)
val lines = StringBuilder()
val keep = HashSet<String>()
val active = state.sub("active")
for (pid in active.keys()) {
val p = all.firstOrNull { it.id == pid } ?: continue
val comps = active.optJSONObject(pid) ?: continue
for (comp in comps.keys()) {
val a = comps.optJSONObject(comp) ?: continue
if (a.optString("protonVersion") != p.version) continue
val file = a.optString("file")
val (key, wcp) = if (file.startsWith("original ")) {
val v = file.removePrefix("original ")
"original-$pid-${safeName(v)}-$comp" to File(originalsDir(context), "$pid/${safeName(v)}/$comp.wcp")
} else safeName(file.removeSuffix(".wcp")) to File(packagesDir(context), file)
if (!wcp.isFile) continue
val unpacked = File(store, key)
if (!File(unpacked, ".complete").isFile) {
unpacked.deleteRecursively()
readWcp(wcp) { tar ->
while (true) {
val e = tar.nextTarEntry ?: break
val rel = normalize(e.name)
if (e.isDirectory || !rel.startsWith("files/") || rel.split('/').any { it == ".." }) continue
val out = File(unpacked, rel)
out.parentFile?.mkdirs()
FileOutputStream(out).use { tar.copyTo(it) }
}
}
File(unpacked, ".complete").writeText("1\n")
}
keep += key
val files = a.optJSONObject("files") ?: continue
for (rel in files.keys()) {
val src = File(unpacked, rel)
if (!src.isFile) continue
val guestSrc = "/" + src.absolutePath.removePrefix(root(context).absolutePath.trimEnd('/')).trimStart('/')
lines.append(p.dir.name).append('\t').append(p.version).append('\t').append(comp).append('\t')
.append(rel).append('\t').append(files.optString(rel)).append('\t').append(guestSrc).append('\n')
}
}
}
store.listFiles()?.filter { it.isDirectory && it.name !in keep }?.forEach { it.deleteRecursively() }
val list = File(dir, "desired.tsv")
val tmp = File(dir, "desired.tsv.tmp")
tmp.writeText(lines.toString())
tmp.renameTo(list)
}
// ------------------------------------------------------------------ actions
/** Applies a package now, or queues it when a game is running on that Proton. Returns what happened. */
fun swap(context: Context, protonId: String, file: String): String = synchronized(lock) {
val p = proton(context, protonId)
val wcp = File(packagesDir(context), safeName(file))
val info = packageInfo(wcp)
val state = loadState(context)
if (inUse(p)) {
state.sub("queued").sub(p.id).put(info.comp, JSONObject().put("kind", "package").put("file", wcp.name).put("label", info.version))
saveState(context, state)
return "${LABEL[info.comp]} ${info.version} goes into ${p.name} when the game running on it closes."
}
applyPackage(context, p, info, wcp, state)
state.sub("queued").optJSONObject(p.id)?.remove(info.comp)
saveState(context, state)
"${LABEL[info.comp]} ${info.version} is now in ${p.name}. It applies the next time a game starts."
}
private fun applyPackage(context: Context, p: Proton, info: Package, wcp: File, state: JSONObject) {
ensureOriginal(context, p, info.comp, state)
val hashes = installFiles(context, p.dir, info.comp, wcp)
state.sub("active").sub(p.id).put(info.comp, JSONObject()
.put("file", wcp.name).put("label", info.version).put("protonVersion", p.version)
.put("files", JSONObject(hashes.filterKeys { owned(info.comp, it) } as Map<*, *>))
.put("at", System.currentTimeMillis() / 1000))
Log.i(TAG, "swapped ${info.comp} ${info.version} into ${p.name} ${p.version}")
}
fun restore(context: Context, protonId: String, comp: String, protonVersion: String): String = synchronized(lock) {
val p = proton(context, protonId)
val wcp = File(originalsDir(context), "$protonId/${safeName(protonVersion)}/$comp.wcp")
check(wcp.isFile) { "That original bundle is not stored" }
val state = loadState(context)
if (inUse(p)) {
state.sub("queued").sub(p.id).put(comp, JSONObject().put("kind", "original").put("protonVersion", protonVersion).put("label", "Original $protonVersion"))
saveState(context, state)
return "${LABEL[comp]} of ${p.name} goes back to its original when the game running on it closes."
}
applyOriginal(context, p, comp, protonVersion, wcp, state)
state.sub("queued").optJSONObject(p.id)?.remove(comp)
saveState(context, state)
"${LABEL[comp]} of ${p.name} restored from its $protonVersion original."
}
private fun applyOriginal(context: Context, p: Proton, comp: String, protonVersion: String, wcp: File, state: JSONObject) {
val current = protonVersion == safeName(p.version)
if (!current) ensureOriginal(context, p, comp, state)
installFiles(context, p.dir, comp, wcp)
val active = state.sub("active").sub(p.id)
if (current) active.remove(comp)
else active.put(comp, JSONObject().put("file", "original $protonVersion").put("label", "Original from $protonVersion")
.put("protonVersion", p.version).put("files", JSONObject(fingerprint(p.dir, comp) as Map<*, *>)).put("at", System.currentTimeMillis() / 1000))
}
/** Runs the swaps that waited for a game to close. Cheap when nothing is queued. */
fun applyQueued(context: Context): List<String> = synchronized(lock) {
val state = loadState(context)
val queued = state.sub("queued")
if (queued.length() == 0) return emptyList()
val done = mutableListOf<String>()
val all = protons(context)
for (pid in queued.keys().asSequence().toList()) {
val p = all.firstOrNull { it.id == pid } ?: run { queued.remove(pid); null } ?: continue
if (inUse(p)) continue
val comps = queued.optJSONObject(pid) ?: continue
for (comp in comps.keys().asSequence().toList()) {
val q = comps.optJSONObject(comp) ?: continue
runCatching {
if (q.optString("kind") == "original") {
val v = q.optString("protonVersion")
applyOriginal(context, p, comp, v, File(originalsDir(context), "$pid/${safeName(v)}/$comp.wcp"), state)
} else {
val wcp = File(packagesDir(context), q.optString("file"))
applyPackage(context, p, packageInfo(wcp), wcp, state)
}
done += "${LABEL[comp]} ${q.optString("label")} → ${p.name}"
}.onFailure { Log.w(TAG, "queued $comp for ${p.name}", it) }
comps.remove(comp)
}
if (comps.length() == 0) queued.remove(pid)
}
saveState(context, state)
done
}
fun cancelQueued(context: Context, protonId: String, comp: String) = synchronized(lock) {
val state = loadState(context)
state.sub("queued").optJSONObject(protonId)?.remove(comp)
saveState(context, state)
}
fun deletePackage(context: Context, file: String): String = synchronized(lock) {
val name = safeName(file)
val state = loadState(context)
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) { "$name is in use in $pid. Swap that Proton to something else first." }
}
val f = File(packagesDir(context), name)
check(f.isFile) { "$name is not stored" }
f.delete()
"Deleted $name."
}
fun deleteOriginal(context: Context, protonId: String, comp: String, protonVersion: String): String = synchronized(lock) {
val p = proton(context, protonId)
check(safeName(p.version) != protonVersion) { "The installed build's original is kept: it is the way back." }
val f = File(originalsDir(context), "$protonId/${safeName(protonVersion)}/$comp.wcp")
check(f.isFile) { "Not stored" }
f.delete()
f.parentFile?.takeIf { it.listFiles().isNullOrEmpty() }?.delete()
"Deleted the $protonVersion original of ${LABEL[comp]}."
}
/** Imports a -linux .wcp from anywhere the app can read (the file picker hands over a copy). */
fun importPackage(context: Context, source: File, name: String): Package = synchronized(lock) {
val info = packageInfo(source)
val dest = File(packagesDir(context), safeName(name).let { if (it.endsWith(".wcp")) it else "$it.wcp" })
source.copyTo(File(dest.parentFile, dest.name + ".part"), overwrite = true).renameTo(dest)
info.copy(file = dest.name)
}
// ------------------------------------------------------------------ catalog
/** The Nightlies listing: cached on disk, fetched again only when [force] (the refresh button). */
fun catalog(context: Context, force: Boolean): Catalog {
val cache = catalogFile(context)
if (!force && cache.isFile) runCatching { return parseCatalog(JSONObject(cache.readText())) }
if (!force) return Catalog(emptyList(), 0)
val items = JSONArray()
for ((tag, comp) in RELEASES) {
val body = Downloader.downloadString("https://api.github.com/repos/$NIGHTLIES/releases/tags/$tag") ?: continue
val assets = runCatching { JSONObject(body).optJSONArray("assets") }.getOrNull() ?: continue
for (i in 0 until assets.length()) {
val a = assets.getJSONObject(i)
val name = a.optString("name")
if (!name.endsWith(".wcp")) continue
items.put(JSONObject().put("file", name).put("comp", comp).put("release", tag)
.put("url", a.optString("browser_download_url")).put("size", a.optLong("size")).put("digest", a.optString("digest")))
}
}
val out = JSONObject().put("items", items).put("fetchedAt", System.currentTimeMillis() / 1000)
val tmp = File(cache.parentFile, cache.name + ".tmp")
tmp.writeText(out.toString()); tmp.renameTo(cache)
return parseCatalog(out)
}
private fun parseCatalog(o: JSONObject): Catalog {
val arr = o.optJSONArray("items") ?: JSONArray()
val items = (0 until arr.length()).map { arr.getJSONObject(it) }.map {
CatalogItem(it.optString("file"), it.optString("comp"), it.optString("release"), it.optString("url"), it.optLong("size"), it.optString("digest"))
}.sortedWith(compareBy({ it.comp }, { it.release }, { it.file }))
return Catalog(items, o.optLong("fetchedAt"))
}
/** Downloads a catalog item into storage, verifying the release's sha256 and the package type. */
fun download(context: Context, item: CatalogItem, progress: (Int) -> Unit): Package {
require(item.url.startsWith("https://github.com/$NIGHTLIES/releases/download/")) { "Downloads come only from the Nightlies releases" }
val dest = File(packagesDir(context), safeName(item.file))
val part = File(dest.parentFile, dest.name + ".part")
part.delete()
check(Downloader.downloadFile(item.url, part, false) { f -> progress(if (f < 0) -1 else (f * 100).toInt().coerceIn(0, 100)) }) { "Download failed" }
if (item.digest.startsWith("sha256:")) {
if (!sha256(part).equals(item.digest.substringAfter(':'), true)) { part.delete(); error("Checksum mismatch") }
}
val info = runCatching { packageInfo(part) }.getOrElse { part.delete(); throw it }
synchronized(lock) { part.renameTo(dest) }
return info.copy(file = dest.name)
}
}
@@ -31,6 +31,9 @@ object SessionArtifacts {
/** Written last; a folder without it did not get its ending. */
const val COMPLETE_MARKER = ".complete"
/** Every file in the folder has been through the redactor, own addresses and accounts included (-2: accounts added). */
private const val SCRUBBED_MARKER = ".scrubbed-2"
/** Everything the end of a session gathers, into [dir]. Safe to call for a dead session. */
fun collect(context: Context, dir: File, reason: String) {
try {
@@ -40,9 +43,12 @@ object SessionArtifacts {
src.copyTo(wayland, overwrite = true)
}
}
LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context))
copySteamLogs(context, dir)
// A session the system killed leaves its trace here and nowhere else.
SessionLogCapture.dumpCrashBuffer(File(dir, "crash.log"))
scrubFolder(dir)
try { File(dir, SCRUBBED_MARKER).writeText("scrubbed ${now()}\n") } catch (e: Exception) {}
SessionEvents.record("session.artifacts_collected", mapOf("reason" to reason), dir)
File(dir, COMPLETE_MARKER).writeText("collected: $reason at ${now()}\n")
} catch (e: Exception) {
@@ -50,6 +56,47 @@ object SessionArtifacts {
}
}
/**
* Once, for session folders written before every file was scrubbed and before the device's own
* addresses were (network.txt listed them; the client's IPv6 check logs "external address"
* into steam/connection_log.txt): the whole folder, steam/ included, through the redactor.
* A marker records it. Runs at app start with [finishAbandoned].
*/
@Synchronized
fun scrubOlder(context: Context) {
val current = SessionPaths.current()
val dirs = LinuxRuntime.debugLogDir().listFiles { f ->
f.isDirectory && f.name.startsWith("session-") && f != current && !File(f, SCRUBBED_MARKER).exists()
} ?: return
if (dirs.isEmpty()) return
LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context))
dirs.forEach { dir ->
scrubFolder(dir)
File(dir, "steam").takeIf { it.isDirectory }?.let { scrubFolder(it) }
try { File(dir, SCRUBBED_MARKER).writeText("scrubbed ${now()}\n") } catch (e: Exception) {}
}
Log.i(TAG, "scrubbed ${dirs.size} older session folder(s)")
}
/**
* The session's own files (session.log, app.log, desktop.log, the Steam desktop client's
* steam-desktop.log, ...) were written as they happened, unscrubbed; this pass puts every one
* through the redactor before the folder can be shared. A file is rewritten only if a line
* changed. steam/ was scrubbed on the way in.
*/
private fun scrubFolder(dir: File) {
dir.listFiles { f -> f.isFile && !f.name.startsWith(".") }?.forEach { f ->
if (!LogRedactor.isText(f)) return@forEach
try {
val tmp = File(dir, ".${f.name}.scrub")
tmp.bufferedWriter().use { w -> LogRedactor.scrubTo(f, w) }
if (tmp.length() != f.length() || tmp.readBytes().contentEquals(f.readBytes()).not()) tmp.renameTo(f) else tmp.delete()
} catch (e: Exception) {
Log.w(TAG, "could not scrub ${f.name}", e)
}
}
}
/** Steam's logs: redacted into steam/, never copied verbatim. */
private fun copySteamLogs(context: Context, dir: File) {
val logs = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/logs")
@@ -81,6 +128,7 @@ object SessionArtifacts {
}?.sortedBy { it.name } ?: return
if (abandoned.isEmpty()) return
val current = SessionPaths.current()
LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context))
abandoned.forEachIndexed { i, dir ->
if (dir == current) return@forEachIndexed
val newest = i == abandoned.lastIndex
@@ -100,6 +148,7 @@ object SessionArtifacts {
}
SessionLogCapture.dumpCrashBuffer(File(dir, "crash.log"))
SessionEvents.record("session.artifacts_recovered", mapOf("newest" to newest), dir)
scrubFolder(dir)
File(dir, COMPLETE_MARKER).writeText("collected: late, at next app start, ${now()}\n")
Log.i(TAG, "finished the abandoned session folder $dir")
} catch (e: Exception) {
@@ -4,6 +4,7 @@ import android.content.ClipData
import android.content.Context
import android.content.Intent
import androidx.core.content.FileProvider
import com.droiddeck.launcher.core.LogRedactor
import com.droiddeck.launcher.runtime.LinuxRuntime
import java.io.File
import java.util.zip.ZipEntry
@@ -31,10 +32,19 @@ object SessionLogShare {
if (files.isEmpty()) return null
val out = File(context.cacheDir, "shared-logs").apply { deleteRecursively(); mkdirs() }
val zip = File(out, "DroidDeck-${folder.name}.zip")
// Scrubbed on the way into the zip: a session shared while it runs has not had its end-of-
// session pass yet, and the redactor changes nothing in a line that is already clean.
LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context))
ZipOutputStream(zip.outputStream().buffered()).use { z ->
files.forEach { f ->
z.putNextEntry(ZipEntry(folder.name + "/" + f.relativeTo(folder).path))
f.inputStream().use { it.copyTo(z) }
if (LogRedactor.isText(f)) {
val w = z.bufferedWriter()
LogRedactor.scrubTo(f, w)
w.flush()
} else {
f.inputStream().use { it.copyTo(z) }
}
z.closeEntry()
}
}
@@ -0,0 +1,450 @@
package com.droiddeck.launcher.ui
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material.icons.outlined.Refresh
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.session.ComponentsManager
import com.droiddeck.launcher.session.ComponentsManager.CatalogItem
import com.droiddeck.launcher.session.ComponentsManager.Snapshot
import java.text.DateFormat
import java.util.Date
/** One installed row: a Proton's original bundle (one per Proton build) or a stored package. */
private class InstalledItem(
val name: String, val detail: String, val tag: String,
val selected: Boolean, val removable: Boolean,
/** Stored package file, or null for an original. */
val file: String?,
/** The Proton build an original belongs to. */
val protonVersion: String?,
)
private val ROW_HEIGHT: Dp = 40.dp
private val DETAIL_WIDTH: Dp = 260.dp
private val ACTION_WIDTH: Dp = 112.dp
private val GOLD = Color(0xFFF2C66D)
/**
* FEX, DXVK and VKD3D-Proton per Proton, as a full page opened from the rail's "Components" entry.
* Compact on purpose (layout A of the mock): title, Proton drop-down, component tabs, what is in
* use and refresh share one toolbar line, the explanation sits behind the (i), and installed rows
* and the Nightlies' downloads run as one list of short rows - a landscape screen shows a dozen or
* more instead of two. Tapping an installed row swaps it in; the refresh button is the only thing
* that goes online.
*/
@Composable
fun ComponentsPage(
snapshot: Snapshot?,
catalog: List<CatalogItem>,
catalogAt: Long,
protonId: String?,
comp: String,
checking: Boolean,
busy: String?,
downloads: Map<String, Int>,
onProton: (String) -> Unit,
onComp: (String) -> Unit,
onSwap: (String) -> Unit,
onRestore: (String) -> Unit,
onCancelQueued: () -> Unit,
onDeletePackage: (String) -> Unit,
onDeleteOriginal: (String) -> Unit,
onDownload: (CatalogItem) -> Unit,
onRefresh: () -> Unit,
onImport: () -> Unit,
onBack: () -> Unit,
) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
BackHandler(onBack = onBack)
var confirm by remember { mutableStateOf<Pair<String, () -> Unit>?>(null) }
var confirmTitle by remember { mutableStateOf("") }
var about by remember { mutableStateOf(false) }
var protonMenu by remember { mutableStateOf(false) }
fun ask(title: String, body: String, action: () -> Unit) { confirmTitle = title; confirm = body to action }
val views = snapshot?.protons ?: emptyList()
val view = views.firstOrNull { it.proton.id == protonId } ?: views.firstOrNull()
val label = ComponentsManager.LABEL[comp] ?: comp
val checked = if (catalogAt > 0) DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(catalogAt * 1000)) else "never"
Column(Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 8.dp)) {
// ---- the toolbar: one line ------------------------------------------------------------
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(bottom = 6.dp)) {
FocusText("‹ Back", colors.onSurfaceVariant, onClick = onBack)
Text("Components", fontSize = 20.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 1)
if (view != null) {
Box {
ValueChip(view.proton.name, protonMenu, modifier = Modifier.widthIn(max = 260.dp)) { protonMenu = !protonMenu }
AnchoredMenu(protonMenu, onDismiss = { protonMenu = false }, title = "Proton",
note = "Valve's own Proton is replaced when Steam updates it; its originals are kept per build.") { first ->
views.forEachIndexed { i, v ->
val swaps = v.components.values.count { it.activeFile != null }
MenuItem(
v.proton.name, checked = v.proton.id == view.proton.id,
detail = v.proton.version + (if (swaps > 0) " · $swaps swapped" else " · all original") + (if (v.inUseByGame) " · game running" else ""),
focusRequester = if (i == 0) first else null,
) { onProton(v.proton.id); protonMenu = false }
}
}
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
BumperHint("LB")
Segmented(ComponentsManager.COMPONENTS.map { it to ComponentsManager.LABEL.getValue(it) }, comp, onComp)
BumperHint("RB")
}
val st = view.components.getValue(comp)
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
Box(Modifier.size(8.dp).background(if (st.queued != null) Color(0xFFFFB86B) else pal.good, CircleShape))
Spacer(Modifier.width(6.dp))
val fixedAt = if (view.reappliedAt > 0) " · re-applied at launch " + DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(view.reappliedAt * 1000)) else ""
Text(
(if (st.queued != null) "Next: ${st.queued} · after the game" else "In use: ${st.inUse}") + fixedAt,
fontSize = 12.5.sp, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false),
)
if (st.queued != null) { Spacer(Modifier.width(8.dp)); FocusText("Cancel", pal.signal, onClick = onCancelQueued) }
}
} else Spacer(Modifier.weight(1f))
Text(if (busy != null) "$busy…" else "Nightlies · $checked", fontSize = 11.5.sp, color = colors.onSurfaceVariant, maxLines = 1)
ToolIcon(Icons.Outlined.Info, "About Components") { about = true }
ToolIcon(Icons.Outlined.Refresh, "Check the Nightlies for new packages", busy = checking, enabled = !checking, onClick = onRefresh)
}
Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
// ---- the list ---------------------------------------------------------------------------
when {
snapshot == null -> Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(16.dp)) {
CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(12.dp))
Text("Reading the Protons…", fontSize = 13.sp, color = colors.onSurfaceVariant)
}
view == null -> Text(
"No Proton is installed in the Linux runtime yet. Start the Steam client once so it downloads its ARM64 Proton, or add GE-Proton / CachyOS from Setup.",
fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(16.dp),
)
else -> {
val p = view.proton
val st = view.components.getValue(comp)
val build = ComponentsManager.safeName(p.version)
val originals = view.originals.filter { it.comp == comp }
val stored = snapshot.packages.filter { it.comp == comp }
val activeIsOriginal = st.activeFile == null
val rows = originals.map { o ->
val isCurrent = o.protonVersion == build
InstalledItem(
"Original · ${o.protonVersion}",
o.label.substringAfterLast(" · ", "").ifEmpty { o.label } + if (isCurrent) " · this build" else " · earlier build",
"ORIGINAL", activeIsOriginal && isCurrent, !isCurrent, null, o.protonVersion,
)
} + stored.map { s ->
InstalledItem(s.version, String.format("%.1f MB", s.size / 1048576.0), "STORED", s.file == st.activeFile, true, s.file, null)
}
val storedNames = snapshot.packages.map { it.file }.toSet()
val available = catalog.filter { it.comp == comp && ComponentsManager.safeName(it.file) !in storedNames }
val first = remember { FocusRequester() }
LaunchedEffect(p.id, comp) { runCatching { first.requestFocus() } }
LazyColumn(Modifier.fillMaxWidth().weight(1f)) {
item { SectionLabel("Installed · ${rows.size}") }
if (rows.isEmpty()) item {
Text("Nothing stored for $label yet. This Proton's own files are saved as its original when the page opens.",
fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp))
}
items(rows.size) { i ->
val item = rows[i]
InstalledLine(
item, modifier = if (i == 0) Modifier.focusRequester(first) else Modifier,
onSelect = {
if (item.selected) return@InstalledLine
val waits = if (view.inUseByGame) " A game is running on this Proton, so it waits until the game closes." else " It applies the next time a game starts."
if (item.file != null) ask("Swap $label?", "Put ${item.name} into ${p.name}? Its shipped files stay saved as an original bundle.$waits") { onSwap(item.file) }
else ask("Restore $label?", "Put the ${item.protonVersion} original back into ${p.name}?$waits") { onRestore(item.protonVersion!!) }
},
onDelete = {
if (item.file != null) ask("Delete ${item.name}?", "Its package file is removed from the app. You can download it again any time.") { onDeletePackage(item.file) }
else ask("Delete this original?", "The ${item.protonVersion} original of $label belongs to an earlier build of ${p.name}. The installed build's original is always kept.") { onDeleteOriginal(item.protonVersion!!) }
},
)
}
item { SectionLabel("Available on the Nightlies · ${available.size}") }
if (available.isEmpty()) item {
Text(if (catalogAt == 0L) "Tap refresh to list the packages on the Nightlies." else "Nothing new from the last check.",
fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp))
}
items(available.size) { i ->
val d = available[i]
AvailableLine(d, downloads[d.file], enabled = busy == null) { onDownload(d) }
}
item {
Row(horizontalArrangement = Arrangement.spacedBy(14.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(horizontal = 8.dp, vertical = 10.dp)) {
SmallButton("Import .wcp", onClick = onImport)
if (!activeIsOriginal && originals.any { it.protonVersion == build }) FocusText("Restore this Proton's original", pal.signal) {
ask("Restore $label?", "Put ${p.name}'s original $label back?") { onRestore(build) }
}
}
}
}
}
}
}
if (about) AlertDialog(
onDismissRequest = { about = false },
title = { Text("Components") },
text = {
Text(
"FEX, DXVK and VKD3D-Proton for each Proton the Steam client runs games with.\n\n" +
"Tap an installed row to swap it in; it applies the next time a game starts. If a game is running on that Proton, the swap waits until it closes.\n\n" +
"Your choices are also checked right before every game launch: if anything changed a Proton's files (Steam's start-up tests, for one), they are put back first, and the page says \"re-applied at launch\".\n\n" +
"Each Proton build's own files are kept as its ORIGINAL, so a Steam update never loses them. Packages come from the Nightlies \"-Linux\" releases (refresh) or Import .wcp.",
fontSize = 13.sp,
)
},
confirmButton = { FocusText("OK", pal.signal) { about = false } },
)
confirm?.let { (body, action) ->
AlertDialog(
onDismissRequest = { confirm = null },
title = { Text(confirmTitle) },
text = { Text(body, fontSize = 13.sp) },
// Opens on Cancel, so a stray A press on a controller never swaps or deletes anything.
confirmButton = {
val del = confirmTitle.startsWith("Delete")
FocusText(if (del) "Delete" else if (confirmTitle.startsWith("Restore")) "Restore" else "Swap", if (del) colors.error else pal.signal) { confirm = null; action() }
},
dismissButton = {
val cancelFocus = remember { FocusRequester() }
LaunchedEffect(Unit) { runCatching { cancelFocus.requestFocus() } }
FocusText("Cancel", colors.onBackground, modifier = Modifier.focusRequester(cancelFocus)) { confirm = null }
},
)
}
}
/** The pad's bumper that turns the component tabs, as a small badge beside them. */
@Composable
private fun BumperHint(text: String) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
Text(
text, fontSize = 10.sp, fontWeight = FontWeight.Bold, letterSpacing = 0.5.sp, color = colors.onSurfaceVariant, maxLines = 1,
modifier = Modifier.clip(RoundedCornerShape(5.dp)).border(1.dp, pal.line2, RoundedCornerShape(5.dp)).padding(horizontal = 5.dp, vertical = 2.dp),
)
}
@Composable
private fun SectionLabel(text: String) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().padding(start = 8.dp, top = 10.dp, bottom = 4.dp)) {
Text(text.uppercase(), fontSize = 10.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.6.sp, color = colors.onSurfaceVariant)
Box(Modifier.weight(1f).height(1.dp).background(pal.line))
}
}
@Composable
private fun Segmented(options: List<Pair<String, String>>, selected: String, onPick: (String) -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
Row(Modifier.clip(RoundedCornerShape(9.dp)).background(colors.surfaceVariant).border(1.dp, pal.line2, RoundedCornerShape(9.dp)).padding(2.dp)) {
for ((key, text) in options) {
val on = key == selected
val src = remember { MutableInteractionSource() }
val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value
val pick = { onPick(key) }
Text(
text, fontSize = 12.5.sp, fontWeight = FontWeight.Bold, maxLines = 1,
color = if (on) Color.White else if (hot) colors.onBackground else colors.onSurfaceVariant,
modifier = Modifier.clip(RoundedCornerShape(7.dp))
.background(if (on) pal.signal else if (hot) pal.signal.copy(alpha = 0.18f) else Color.Transparent)
.border(if (hot && !on) 1.dp else 0.dp, if (hot && !on) pal.signal else Color.Transparent, RoundedCornerShape(7.dp))
.hoverable(src).clickable(interactionSource = src, indication = null, onClick = pick)
.controllerConfirm(onClick = pick)
.padding(horizontal = 12.dp, vertical = 5.dp),
)
}
}
}
@Composable
private fun ToolIcon(icon: ImageVector, description: String, busy: Boolean = false, enabled: Boolean = true, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(34.dp).clip(RoundedCornerShape(9.dp))
.background(if (hot) pal.signal.copy(alpha = 0.16f) else Color.Transparent)
.border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line2, RoundedCornerShape(9.dp))
.hoverable(src).clickable(interactionSource = src, indication = null, enabled = enabled, onClick = onClick)
.controllerConfirm(enabled = enabled, onClick = onClick),
) {
if (busy) CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(16.dp))
else Icon(icon, contentDescription = description, tint = colors.onBackground, modifier = Modifier.size(18.dp))
}
}
/**
* A small button. Resting: grey outline (blue text when [accent]). Focused or hovered: a solid blue
* fill, white text and a white outline - unmistakable in a column of identical buttons.
*/
@Composable
private fun SmallButton(text: String, enabled: Boolean = true, accent: Boolean = false, modifier: Modifier = Modifier, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = (src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value) && enabled
Box(
contentAlignment = Alignment.Center,
modifier = modifier.clip(RoundedCornerShape(8.dp))
.background(if (hot) pal.signal else colors.surfaceVariant)
.border(2.dp, if (hot) Color.White else pal.line2, RoundedCornerShape(8.dp))
.hoverable(src).clickable(interactionSource = src, indication = null, enabled = enabled, onClick = onClick)
.controllerConfirm(enabled = enabled, onClick = onClick)
.padding(horizontal = 10.dp, vertical = 5.dp),
) {
Text(
text, fontSize = 12.sp, fontWeight = FontWeight.Bold, maxLines = 1,
color = if (!enabled) colors.onSurfaceVariant else if (hot) Color.White else if (accent) Color(0xFF5E9BFF) else colors.onBackground,
)
}
}
@Composable
private fun Tag(text: String) {
val colors = MaterialTheme.colorScheme
val (fg, bg) = when (text) {
"ORIGINAL" -> GOLD to Color(0x22F2C66D)
"IN USE" -> Color.White to LocalPalette.current.signal
"NEW" -> LocalPalette.current.good to Color(0x224CD37F)
else -> colors.onSurfaceVariant to Color.White.copy(alpha = 0.07f)
}
Text(
text, fontSize = 9.sp, fontWeight = FontWeight.Bold, letterSpacing = 0.8.sp, color = fg, maxLines = 1,
modifier = Modifier.clip(RoundedCornerShape(4.dp)).background(bg).padding(horizontal = 5.dp, vertical = 1.dp),
)
}
@Composable
private fun InstalledLine(item: InstalledItem, modifier: Modifier = Modifier, onSelect: () -> Unit, onDelete: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value
val shape = RoundedCornerShape(8.dp)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().heightIn(min = ROW_HEIGHT).clip(shape)
.background(if (hot) pal.signal.copy(alpha = 0.14f) else if (item.selected) pal.signal.copy(alpha = 0.08f) else Color.Transparent)
.border(2.dp, if (hot) pal.signal else Color.Transparent, shape),
) {
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.weight(1f).heightIn(min = ROW_HEIGHT).then(modifier)
.hoverable(src).clickable(interactionSource = src, indication = null, onClick = onSelect)
.controllerConfirm(onClick = onSelect)
.padding(horizontal = 10.dp),
) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(16.dp).border(2.dp, if (item.selected) pal.signal else colors.onSurfaceVariant, CircleShape)) {
if (item.selected) Box(Modifier.size(7.dp).background(pal.signal, CircleShape))
}
Text(
item.name, fontSize = 13.5.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis,
color = if (item.selected) Color(0xFF5E9BFF) else colors.onBackground, modifier = Modifier.weight(1f),
)
Box(Modifier.width(72.dp)) { Tag(item.tag) }
Text(item.detail, fontSize = 11.5.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.width(DETAIL_WIDTH))
Box(Modifier.width(56.dp), contentAlignment = Alignment.CenterEnd) { if (item.selected) Tag("IN USE") }
}
// The trash column is always there (empty when the row can't be deleted) so rows line up.
if (!item.removable) Spacer(Modifier.padding(end = 4.dp).size(32.dp))
else {
val delSrc = remember { MutableInteractionSource() }
val delHot = delSrc.collectIsFocusedAsState().value || delSrc.collectIsHoveredAsState().value
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.padding(end = 4.dp).size(32.dp).clip(RoundedCornerShape(8.dp))
.border(2.dp, if (delHot) colors.error else Color.Transparent, RoundedCornerShape(8.dp))
.hoverable(delSrc).clickable(interactionSource = delSrc, indication = null, onClick = onDelete)
.controllerConfirm(onClick = onDelete),
) { Icon(Icons.Outlined.Delete, contentDescription = "Delete", tint = if (delHot) colors.error else colors.onSurfaceVariant, modifier = Modifier.size(17.dp)) }
}
}
Box(Modifier.fillMaxWidth().padding(horizontal = 8.dp).height(1.dp).background(pal.line.copy(alpha = 0.5f)))
}
@Composable
private fun AvailableLine(d: CatalogItem, progress: Int?, enabled: Boolean, onDownload: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxWidth().heightIn(min = ROW_HEIGHT).padding(horizontal = 10.dp),
) {
// Fixed columns so every row's details and button line up: name | details | button.
Tag("NEW")
Text(d.file.removeSuffix(".wcp"), fontSize = 13.5.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground,
maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
Text("${d.release} · " + String.format("%.1f MB", d.size / 1048576.0), fontSize = 11.5.sp, color = colors.onSurfaceVariant,
fontFamily = FontFamily.Default, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.width(DETAIL_WIDTH))
if (progress == null) SmallButton("Download", enabled = enabled, accent = true, modifier = Modifier.width(ACTION_WIDTH), onClick = onDownload)
else Column(horizontalAlignment = Alignment.End, modifier = Modifier.width(ACTION_WIDTH)) {
Text(if (progress < 0) "…" else "$progress%", fontSize = 11.sp, color = colors.onSurfaceVariant)
Spacer(Modifier.height(3.dp))
if (progress < 0) LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
else LinearProgressIndicator(progress = { progress / 100f }, modifier = Modifier.fillMaxWidth())
}
}
Box(Modifier.fillMaxWidth().padding(horizontal = 8.dp).height(1.dp).background(pal.line.copy(alpha = 0.5f)))
}
@@ -221,7 +221,7 @@ private fun InstalledRow(row: DriverRow, selected: Boolean, onSelect: () -> Unit
/** A text button that shows where controller focus is: an outline in its own colour. */
@Composable
private fun FocusText(text: String, color: Color, modifier: Modifier = Modifier, onClick: () -> Unit) {
internal fun FocusText(text: String, color: Color, modifier: Modifier = Modifier, onClick: () -> Unit) {
val src = remember { MutableInteractionSource() }
val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value
val shape = RoundedCornerShape(8.dp)
@@ -193,6 +193,8 @@ class FrontEndActions(
val onRuntime: () -> Unit,
val onFrameGenPick: (engine: String, multiplier: Int) -> Unit,
val onProtons: () -> Unit,
/** The Components page: FEX / DXVK / VKD3D-Proton per Proton. */
val onComponents: () -> Unit,
val onPerformance: () -> Unit,
val onRoms: () -> Unit,
val onFiles: () -> Unit,
@@ -374,8 +376,13 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
else -> s.pageKey ?: selected
}
val onRailSelect: (String) -> Unit = { key ->
if (s.pageKey != null) a.onPageBack()
selected = key
// Components is a full page like Protons or Performance, opened over the current rail
// selection rather than replacing it.
if (key == "components") a.onComponents()
else {
if (s.pageKey != null) a.onPageBack()
selected = key
}
navOpen = false
}
// Start controllers on the current page's main action; the rail is initially collapsed.
@@ -580,6 +587,7 @@ private fun Rail(
}
NavItem("Steam", "steam", selected == "steam", count = s.steamGames.size, register = register, unregister = unregister) { onSelect("steam") }
NavItem("Desktop", "desktop", selected == "desktop", count = s.emulators.count { it.installed }, register = register, unregister = unregister) { onSelect("desktop") }
NavItem("Components", "components", selected == "components", register = register, unregister = unregister) { onSelect("components") }
NavItem("Setup", "setup", selected == "setup", register = register, unregister = unregister) { onSelect("setup") }
Spacer(Modifier.height(6.dp))
}
@@ -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. */
@@ -1,5 +1,15 @@
package com.droiddeck.launcher.ui
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.offset
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.DesktopWindows
import androidx.compose.material.icons.outlined.Layers
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.SportsEsports
import androidx.compose.material3.Icon
import androidx.compose.ui.draw.clipToBounds
import android.view.Display
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image
@@ -82,16 +92,19 @@ import androidx.compose.ui.text.style.TextOverflow
import com.droiddeck.launcher.core.FexPreset
import com.droiddeck.launcher.gpu.FrameGen
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.session.ComponentsManager
import com.droiddeck.launcher.input.SecondScreenDisplay
import com.droiddeck.launcher.input.SecondScreenMode
import kotlinx.coroutines.flow.collect
private val drawerPageTitles = listOf("Now", "Controls", "Session")
private val drawerPageEntries = listOf("hud", "touch", "suspend")
private val drawerPageTitles = listOf("Display", "Controls", "Components", "Settings")
/** One icon per drawer page, in page order (QAM-style tabs). */
private val drawerPageIcons = listOf(Icons.Outlined.DesktopWindows, Icons.Outlined.SportsEsports, Icons.Outlined.Layers, Icons.Outlined.Settings)
private val drawerPageEntries = listOf("hud", "touch", "cmp-proton", "suspend")
private class DrawerFocus {
private val requesters = HashMap<String, FocusRequester>()
private val last = arrayOfNulls<String>(3)
private val last = arrayOfNulls<String>(DRAWER_PAGES)
var focused by mutableStateOf<String?>(null)
private set
@@ -229,6 +242,12 @@ class DrawerActions(
val onShareLogs: () -> Unit,
val onStop: () -> Unit,
val onClose: () -> Unit,
/** Components tab: every Proton with what it uses; null until first read. */
val components: ComponentsManager.Snapshot? = null,
/** Re-reads the Protons (and runs swaps that waited for a game to close). */
val onComponentsRefresh: () -> Unit = {},
/** Swaps [value] ("orig:<build>" or a stored package file) into a Proton's component. */
val onComponentSwap: (protonId: String, comp: String, value: String) -> Unit = { _, _, _ -> },
)
@OptIn(ExperimentalComposeUiApi::class)
@@ -240,7 +259,7 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
var androidAppsExpanded by rememberSaveable { mutableStateOf(false) }
var appToChooseDisplay by remember { mutableStateOf<HomeApp.LaunchableApp?>(null) }
var confirmStop by remember { mutableStateOf(false) }
val pageScroll = remember { List(3) { ScrollState(0) } }
val pageScroll = remember { List(DRAWER_PAGES) { ScrollState(0) } }
val veil by animateFloatAsState(if (open) 1f else 0f, Motion.tw(260), label = "veil")
val focus = remember { DrawerFocus() }
val inputModeManager = LocalInputModeManager.current
@@ -250,6 +269,7 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
}
BackHandler(enabled = open && confirmStop) { confirmStop = false }
LaunchedEffect(page) { host.open = null; appToChooseDisplay = null }
LaunchedEffect(open, page) { if (open && page == DRAWER_PAGE_COMPONENTS) a.onComponentsRefresh() }
LaunchedEffect(open, controllerActive) {
if (open && !controllerActive) focusManager.clearFocus(force = true)
}
@@ -382,36 +402,11 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
) {
DrawerOutlineButton("LB ‹", modifier = Modifier.height(48.dp).then(focus.track(page, "prev"))) {
host.open = null; onPageChange((page + 2) % 3)
}
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1f),
) {
drawerPageTitles.forEachIndexed { index, title ->
val dotSource = remember { MutableInteractionSource() }
val dotFocused = dotSource.collectIsFocusedAsState().value
val select = { host.open = null; onPageChange(index) }
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(38.dp)
.semantics { contentDescription = "$title page" }
.hoverable(dotSource)
.clickable(interactionSource = dotSource, indication = LocalIndication.current, onClick = select)
.controllerConfirm(onClick = select),
) {
Box(
Modifier.size(if (index == page) 16.dp else 10.dp)
.clip(CircleShape)
.background(if (index == page) pal.signal else colors.onSurfaceVariant)
.border(if (dotFocused) 2.dp else 0.dp, colors.onBackground, CircleShape),
)
}
}
host.open = null; onPageChange((page + DRAWER_PAGES - 1) % DRAWER_PAGES)
}
DrawerPageTabs(page = page, modifier = Modifier.weight(1f)) { index -> host.open = null; onPageChange(index) }
DrawerOutlineButton("› RB", modifier = Modifier.height(48.dp).then(focus.track(page, "next"))) {
host.open = null; onPageChange((page + 1) % 3)
host.open = null; onPageChange((page + 1) % DRAWER_PAGES)
}
}
@@ -419,7 +414,7 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(pageScroll[page]),
) {
when (page) {
0 -> SettingsGroup("Now") {
0 -> SettingsGroup("Display") {
ToggleRow(host, "hud", "Performance HUD", null, a.hudOn,
chipModifier = focus.track(page, "hud"), onChange = a.onHud)
if (a.fillScreen != null) ToggleRow(
@@ -444,6 +439,7 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
}
}
}
DRAWER_PAGE_COMPONENTS -> ComponentsDrawerPage(host, a) { key -> focus.track(page, key) }
1 -> {
SettingsGroup("Controls") {
ChoiceRow(host, "touch", "Touch", null,
@@ -637,3 +633,138 @@ private fun ShareSessionLogsButton(modifier: Modifier = Modifier, onClick: () ->
color = if (hot) pal.signal else colors.onSurfaceVariant, maxLines = 1)
}
}
/**
* The drawer's page tabs, QAM-style: one icon per page standing in a line in page order. The
* selected one steps forward - full size, bright, a soft glow - while the others step back to
* half size and fade, and the whole line leans a little toward the selection. LB / RB still turn
* the page (the buttons beside it and the pad's bumpers); a tab can be tapped or picked with the
* pad like the dots it replaces.
*/
@Composable
private fun DrawerPageTabs(page: Int, modifier: Modifier = Modifier, onSelect: (Int) -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val count = drawerPageIcons.size
val middle = (count - 1) / 2f
val motion = tween<Float>(durationMillis = 340, easing = FastOutSlowInEasing)
androidx.compose.foundation.layout.BoxWithConstraints(
contentAlignment = Alignment.Center,
modifier = modifier.height(64.dp).clipToBounds(),
) {
// Fit the line to the room between LB and RB. Worst case is an end tab selected: full size
// (56 dp) and pushed outward by the lean, which scales with the spacing. So
// (count-1) * spacing * (1 + lean/64) + 56 <= width
// - never more than the mock's 64 dp, never tighter than 32 dp.
val spacing = ((maxWidth.value - 56f) / ((count - 1) * (1f + DRAWER_TAB_LEAN_DP / DRAWER_TAB_SPACING_DP))).coerceIn(32f, DRAWER_TAB_SPACING_DP)
val leanStep = DRAWER_TAB_LEAN_DP * spacing / DRAWER_TAB_SPACING_DP
val lean by animateFloatAsState(-(page - middle) * leanStep, motion, label = "tabLean")
drawerPageIcons.forEachIndexed { index, icon ->
val selected = index == page
val source = remember { MutableInteractionSource() }
val focused = source.collectIsFocusedAsState().value
val scale by animateFloatAsState(if (selected) 1f else DRAWER_TAB_SIDE_SCALE, motion, label = "tabScale")
val alpha by animateFloatAsState(if (selected) 1f else DRAWER_TAB_SIDE_ALPHA, motion, label = "tabAlpha")
val glow by animateFloatAsState(if (selected) 1f else 0f, motion, label = "tabGlow")
val tint by animateColorAsState(if (selected) colors.onBackground else colors.onSurfaceVariant, tween(340), label = "tabTint")
val select = { onSelect(index) }
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.offset(x = ((index - middle) * spacing + lean).dp)
.size(56.dp)
.graphicsLayer { scaleX = scale; scaleY = scale; this.alpha = alpha }
.clip(RoundedCornerShape(16.dp))
.background(
Brush.radialGradient(
listOf(pal.signal.copy(alpha = 0.34f * glow), pal.signal.copy(alpha = 0.10f * glow), Color.Transparent),
),
)
.border(if (focused) 2.dp else 0.dp, if (focused) colors.onBackground else Color.Transparent, RoundedCornerShape(16.dp))
.semantics { contentDescription = "${drawerPageTitles[index]} page" }
.hoverable(source)
.clickable(interactionSource = source, indication = LocalIndication.current, onClick = select)
.controllerConfirm(onClick = select),
) {
Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(30.dp))
}
}
}
}
/** Display, Controls, Components, Settings. */
const val DRAWER_PAGES = 4
private const val DRAWER_PAGE_COMPONENTS = 2
/**
* The drawer's Components tab: quick swaps between what is already installed, per Proton. A swap
* into the Proton a running game uses waits until that game closes; downloading, importing and
* deleting stay on the Components page in the app.
*/
@Composable
private fun ComponentsDrawerPage(host: MenuHost, a: DrawerActions, track: (String) -> Modifier) {
val colors = MaterialTheme.colorScheme
val snap = a.components
if (snap == null) {
SettingsGroup("Components") {
Text("Reading the Protons…", fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(14.dp))
}
return
}
val running = snap.protons.filter { it.inUseByGame }
var pick by rememberSaveable { mutableStateOf<String?>(null) }
val view = snap.protons.firstOrNull { it.proton.id == pick } ?: running.firstOrNull() ?: snap.protons.firstOrNull()
SettingsGroup("Running now") {
if (running.isEmpty()) SettingsRow("No game is running on a Proton", "Swaps apply the next time a game starts.") {}
else for (r in running) {
val c = r.components
SettingsRow(
r.proton.name,
ComponentsManager.COMPONENTS.joinToString(" · ") { "${ComponentsManager.LABEL[it]} ${c.getValue(it).inUse}" },
) {}
}
}
if (view == null) {
SettingsGroup("Components") {
Text("No Proton is installed yet.", fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(14.dp))
}
return
}
val p = view.proton
SettingsGroup("Components") {
ChoiceRow(
host, "cmp-proton", "Proton", p.version + (if (view.inUseByGame) " · in use by the running game" else "") +
(if (view.reappliedAt > 0) " · re-applied at launch " + java.text.DateFormat.getTimeInstance(java.text.DateFormat.SHORT).format(java.util.Date(view.reappliedAt * 1000)) else ""),
snap.protons.map { it.proton.id to it.proton.name }, p.id,
chipModifier = track("cmp-proton"), onPick = { pick = it },
)
val build = ComponentsManager.safeName(p.version)
for (comp in ComponentsManager.COMPONENTS) {
val st = view.components.getValue(comp)
val originals = view.originals.filter { it.comp == comp }
val options = originals.map { "orig:${it.protonVersion}" to (if (it.protonVersion == build) "Original" else "Original · ${it.protonVersion}") } +
snap.packages.filter { it.comp == comp }.map { it.file to it.version }
if (options.isEmpty()) continue
val current = st.activeFile ?: "orig:$build"
ChoiceRow(
host, "cmp-$comp", ComponentsManager.LABEL.getValue(comp),
st.queued?.let { "Next: $it · after the game closes" } ?: st.inUse,
options, current,
note = if (view.inUseByGame) "A game is running on this Proton: the change waits until it closes." else "Applies the next time a game starts.",
chipModifier = track("cmp-$comp"),
onPick = { v -> if (v != current || st.queued != null) a.onComponentSwap(p.id, comp, v) },
)
}
}
Text(
"Downloads, importing and deleting are on the Components page in the app.",
fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 4.dp, vertical = 12.dp),
)
}
/** The tab row's measure, from the approved mock: side tabs at half size, a little faded. */
private const val DRAWER_TAB_SPACING_DP = 64f
private const val DRAWER_TAB_LEAN_DP = 12f
private const val DRAWER_TAB_SIDE_SCALE = 0.5f
private const val DRAWER_TAB_SIDE_ALPHA = 0.7f
+148 -63
View File
@@ -27,6 +27,66 @@ if ! command -v docker >/dev/null 2>&1; then
echo "Docker is required to cross-compile the glibc ARM64 preload libraries." >&2
exit 1
fi
for tool in curl tar zstd shasum unzip; do
if ! command -v "${tool}" >/dev/null 2>&1; then
echo "${tool} is required to build the CI-equivalent APK." >&2
exit 1
fi
done
if [[ -f "${repo_root}/tools/gamescope/release.env" || -f "${repo_root}/tools/wlroots/release.env" ]] \
&& ! command -v gh >/dev/null 2>&1; then
echo "GitHub CLI is required to download the pinned Gamescope and wlroots release assets." >&2
exit 1
fi
ndk_version=${DROIDDECK_NDK_VERSION:-}
if [[ -z "${ndk_version}" ]]; then
ndk_path=$(find "${sdk_dir}/ndk" -mindepth 1 -maxdepth 1 -type d -print | sort -V | tail -1)
ndk_version=${ndk_path##*/}
fi
if [[ -z "${ndk_version}" || ! -d "${sdk_dir}/ndk/${ndk_version}" ]]; then
echo "No Android NDK found under ${sdk_dir}/ndk; set DROIDDECK_NDK_VERSION." >&2
exit 1
fi
export ANDROID_HOME="${sdk_dir}"
export ANDROID_SDK_ROOT="${sdk_dir}"
export JAVA_HOME="${java_dir}"
export NDK="${sdk_dir}/ndk/${ndk_version}"
staging_dir=$(mktemp -d "${TMPDIR:-/tmp}/droiddeck-build.XXXXXX")
bundle_asset="${repo_root}/app/src/main/assets/pulseaudio.tzst"
bundle_backup="${staging_dir}/pulseaudio.original.tzst"
bundle_replaced=0
linuxfs_dir="${repo_root}/app/src/main/assets/linuxfs"
linuxfs_backup="${staging_dir}/linuxfs.original"
linuxfs_preexisting=0
linuxfs_replaced=0
cleanup() {
local exit_code=$?
trap - EXIT
if [[ "${bundle_replaced}" == 1 ]]; then
cp -p "${bundle_backup}" "${bundle_asset}" || exit_code=1
fi
if [[ "${linuxfs_replaced}" == 1 ]]; then
rm -rf -- "${linuxfs_dir}" || exit_code=1
if [[ "${linuxfs_preexisting}" == 1 ]]; then
mv "${linuxfs_backup}" "${linuxfs_dir}" || exit_code=1
fi
fi
rm -rf -- "${staging_dir}" || exit_code=1
exit "${exit_code}"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
if [[ -d "${linuxfs_dir}" ]]; then
cp -a "${linuxfs_dir}" "${linuxfs_backup}"
linuxfs_preexisting=1
fi
linuxfs_replaced=1
rm -rf -- "${linuxfs_dir}"
mkdir -p "${linuxfs_dir}"
if ! docker image inspect "${image_name}" >/dev/null 2>&1; then
docker build --platform linux/amd64 -t "${image_name}" \
@@ -49,7 +109,12 @@ docker run --rm --platform linux/amd64 \
for script in tools/linuxfs/overlay/usr/local/bin/bannerlator-*; 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
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/autostart "$d/etc/xdg/labwc/autostart"
install -Dm644 tools/linuxfs/desktop/rc.xml "$d/etc/xdg/labwc/rc.xml"
install -Dm644 tools/linuxfs/desktop/panel.conf "$d/etc/xdg/lxqt/panel.conf"
@@ -82,42 +147,56 @@ docker run --rm --platform linux/amd64 \
test -f "$d/usr/local/bin/bannerlator-proton-extra"
'
export ANDROID_HOME="${sdk_dir}"
export ANDROID_SDK_ROOT="${sdk_dir}"
export JAVA_HOME="${java_dir}"
ndk_version=${DROIDDECK_NDK_VERSION:-}
if [[ -z "${ndk_version}" ]]; then
ndk_path=$(find "${sdk_dir}/ndk" -mindepth 1 -maxdepth 1 -type d -print | sort -V | tail -1)
ndk_version=${ndk_path##*/}
fi
if [[ -z "${ndk_version}" || ! -d "${sdk_dir}/ndk/${ndk_version}" ]]; then
echo "No Android NDK found under ${sdk_dir}/ndk; set DROIDDECK_NDK_VERSION." >&2
exit 1
github_repo=${DROIDDECK_GITHUB_REPOSITORY:-}
if [[ -z "${github_repo}" ]]; then
origin_url=$(git -C "${repo_root}" remote get-url origin)
case "${origin_url}" in
https://github.com/*) github_repo=${origin_url#https://github.com/} ;;
ssh://[email protected]/*) github_repo=${origin_url#ssh://[email protected]/} ;;
[email protected]:*) github_repo=${origin_url#[email protected]:} ;;
*)
echo "Cannot determine the GitHub repository from origin: ${origin_url}" >&2
echo "Set DROIDDECK_GITHUB_REPOSITORY=owner/repo." >&2
exit 1
;;
esac
github_repo=${github_repo%.git}
fi
for tool in curl tar zstd; do
if ! command -v "${tool}" >/dev/null 2>&1; then
echo "${tool} is required to build the bundled audio modules." >&2
exit 1
fi
if [[ -f "${repo_root}/tools/gamescope/release.env" ]]; then
. "${repo_root}/tools/gamescope/release.env"
gamescope_archive="${staging_dir}/gamescope.tzst"
gh release download "${GAMESCOPE_TAG}" -R "${github_repo}" -p gamescope.tzst -O "${gamescope_archive}"
printf '%s %s\n' "${GAMESCOPE_SHA256}" "${gamescope_archive}" | shasum -a 256 -c -
zstd -dc "${gamescope_archive}" | tar -xf - -C "${linuxfs_dir}"
test -f "${linuxfs_dir}/usr/local/bin/gamescope"
fi
if [[ -f "${repo_root}/tools/wlroots/release.env" ]]; then
. "${repo_root}/tools/wlroots/release.env"
wlroots_archive="${staging_dir}/wlroots.tzst"
gh release download "${WLROOTS_TAG}" -R "${github_repo}" -p wlroots.tzst -O "${wlroots_archive}"
printf '%s %s\n' "${WLROOTS_SHA256}" "${wlroots_archive}" | shasum -a 256 -c -
zstd -dc "${wlroots_archive}" | tar -xf - -C "${linuxfs_dir}"
test -f "${linuxfs_dir}/usr/local/lib/droiddeck-wlroots/libwlroots-0.20.so"
fi
mango_dir="${linuxfs_dir}/usr/local/lib/mangoapp"
mango_pkgs="${staging_dir}/mango-pkgs"
mkdir -p "${mango_dir}" "${mango_pkgs}"
while read -r package_sha256 package_url; do
[[ -n "${package_url}" ]] || continue
package_archive="${staging_dir}/$(basename "${package_url}")"
curl -fsSL --retry 3 -o "${package_archive}" "${package_url}"
printf '%s %s\n' "${package_sha256}" "${package_archive}" | shasum -a 256 -c -
zstd -dc "${package_archive}" | tar -xf - -C "${mango_pkgs}"
done < <(grep -v '^#' "${repo_root}/tools/mangoapp/packages.txt")
install -m644 "${mango_pkgs}/usr/bin/mangoapp" "${mango_dir}/mangoapp"
for library in libfmt.so.10 libspdlog.so.1.13 libglfw.so.3 libtraceevent.so.1 libtracefs.so.1; do
cp -L "${mango_pkgs}/usr/lib/${library}" "${mango_dir}/${library}"
done
staging_dir=$(mktemp -d "${TMPDIR:-/tmp}/droiddeck-build.XXXXXX")
bundle_asset="${repo_root}/app/src/main/assets/pulseaudio.tzst"
bundle_backup="${staging_dir}/pulseaudio.original.tzst"
bundle_replaced=0
cleanup() {
local exit_code=$?
trap - EXIT
if [[ "${bundle_replaced}" == 1 ]]; then
cp -p "${bundle_backup}" "${bundle_asset}" || exit_code=1
fi
rm -rf -- "${staging_dir}" || exit_code=1
exit "${exit_code}"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
mkdir -p "${linuxfs_dir}/usr/local/bin"
install -m644 "${repo_root}/tools/mangoapp/mangoapp" "${linuxfs_dir}/usr/local/bin/mangoapp"
pa_source=${DROIDDECK_PA13_SOURCE_DIR:-"${staging_dir}/pulseaudio-13.0"}
if [[ -z "${DROIDDECK_PA13_SOURCE_DIR:-}" ]]; then
@@ -132,7 +211,6 @@ if [[ ! -f "${pa_source}/src/pulse/version.h.in" ]]; then
exit 1
fi
export NDK="${sdk_dir}/ndk/${ndk_version}"
sink_output="${staging_dir}/sink-out"
"${repo_root}/tools/aaudio-sink/build.sh" "${pa_source}" "${sink_output}"
"${repo_root}/tools/proot/build.sh" "${repo_root}/app/src/main/jniLibs/arm64-v8a"
@@ -155,7 +233,7 @@ bundle_replaced=1
mv "${staging_dir}/pulseaudio.tzst" "${bundle_asset}"
cd "${repo_root}"
./gradlew assembleRelease -PndkVersion="${ndk_version}"
./gradlew assembleRelease --console=plain -PndkVersion="${ndk_version}"
cp -p "${bundle_backup}" "${bundle_asset}"
bundle_replaced=0
@@ -173,33 +251,6 @@ for audio_file in \
fi
done
build_tools=$(find "${sdk_dir}/build-tools" -mindepth 1 -maxdepth 1 -type d -print | sort -V | tail -1)
if [[ ! -x "${build_tools}/zipalign" || ! -x "${build_tools}/apksigner" ]]; then
echo "Android build-tools with zipalign/apksigner are required under ${sdk_dir}/build-tools." >&2
exit 1
fi
"${build_tools}/zipalign" -p -f 4 "${apk}" "${staging_dir}/app-release.aligned.apk"
"${build_tools}/apksigner" sign \
--ks keystore/testkey.p12 --ks-type PKCS12 --ks-pass pass:android \
--ks-key-alias testkey --key-pass pass:android \
--v1-signing-enabled true --v2-signing-enabled true --v3-signing-enabled true \
--out "${apk}" "${staging_dir}/app-release.aligned.apk"
signature_output=$("${build_tools}/apksigner" verify --min-sdk-version 21 --verbose --print-certs "${apk}")
printf '%s\n' "${signature_output}"
for scheme in \
'Verified using v1 scheme (JAR signing): true' \
'Verified using v2 scheme (APK Signature Scheme v2): true' \
'Verified using v3 scheme (APK Signature Scheme v3): true'; do
grep -qF "${scheme}" <<<"${signature_output}" || {
echo "APK signature check failed: ${scheme}" >&2
exit 1
}
done
grep -qF 'Signer #1 certificate DN: [email protected], CN=Android, OU=Android, O=Android' \
<<<"${signature_output}"
docker run --rm --platform linux/amd64 -v "${repo_root}:/src:ro" -w /src "${image_name}" \
bash -lc '
set -euo pipefail
@@ -207,7 +258,7 @@ docker run --rm --platform linux/amd64 -v "${repo_root}:/src:ro" -w /src "${imag
work=$(mktemp -d)
unzip -q "$apk" "lib/arm64-v8a/*" -d "$work"
cd "$work/lib/arm64-v8a"
system="libc.so libm.so libdl.so liblog.so libandroid.so libz.so libvulkan.so
system="libc.so libm.so libdl.so liblog.so libandroid.so libz.so libvulkan.so libstdc++.so
libGLESv2.so libEGL.so libnativewindow.so libjnigraphics.so libaaudio.so
libOpenSLES.so libmediandk.so libcamera2ndk.so libsync.so libneuralnetworks.so"
fail=0
@@ -223,6 +274,40 @@ docker run --rm --platform linux/amd64 -v "${repo_root}:/src:ro" -w /src "${imag
echo "every NEEDED resolves"
'
build_tools=$(find "${sdk_dir}/build-tools" -mindepth 1 -maxdepth 1 -type d -print | sort -V | tail -1)
if [[ ! -x "${build_tools}/zipalign" || ! -x "${build_tools}/apksigner" ]]; then
echo "Android build-tools with zipalign/apksigner are required under ${sdk_dir}/build-tools." >&2
exit 1
fi
"${build_tools}/zipalign" -p -f 4 "${apk}" "${apk}.aligned"
"${build_tools}/apksigner" sign \
--ks "${repo_root}/keystore/testkey.p12" --ks-type PKCS12 --ks-pass pass:android \
--ks-key-alias testkey --key-pass pass:android \
--v1-signing-enabled true --v2-signing-enabled true --v3-signing-enabled true \
--out "${apk}" "${apk}.aligned"
rm -f "${apk}.aligned" "${apk}.idsig"
signature_output=$("${build_tools}/apksigner" verify --min-sdk-version 21 --verbose --print-certs "${apk}")
printf '%s\n' "${signature_output}"
if ! unzip -l "${apk}" | grep -qE 'META-INF/.*\.(SF|RSA|DSA)$'; then
echo "APK signature check failed: JAR signature files are missing." >&2
exit 1
fi
for scheme in \
'Verified using v1 scheme (JAR signing): true' \
'Verified using v2 scheme (APK Signature Scheme v2): true' \
'Verified using v3 scheme (APK Signature Scheme v3): true'; do
grep -qF "${scheme}" <<<"${signature_output}" || {
echo "APK signature check failed: ${scheme}" >&2
exit 1
}
done
grep -qi 'CN=Android, OU=Android, O=Android' <<<"${signature_output}" || {
echo "APK signature check failed: the signer is not the AOSP testkey." >&2
exit 1
}
printf 'APK: %s\n' "${apk}"
printf 'SHA-256: '
shasum -a 256 "${apk}" | awk '{print $1}'
+11
View File
@@ -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 )
+2 -2
View File
@@ -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"