mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-14 19:27:09 -04:00
Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d5358fa85 | ||
|
|
d7ff92106a | ||
|
|
110aec0b6f | ||
|
|
06502bb49b | ||
|
|
c982d65675 | ||
|
|
f4e606fb15 | ||
|
|
9dbdb5e771 | ||
|
|
fa12478fdd | ||
|
|
cbb9a168e6 | ||
|
|
73e2fc58a0 | ||
|
|
500f4c69eb | ||
|
|
8cdedbe18a | ||
|
|
0f39d71f95 | ||
|
|
1dc49b527b | ||
|
|
cc3fec0768 | ||
|
|
5270574e5a | ||
|
|
a8af973726 | ||
|
|
ed1f8dd2b6 | ||
|
|
e8aaae1343 | ||
|
|
036317f371 | ||
|
|
ce0da01163 | ||
|
|
75ae619e21 | ||
|
|
3efa5acfa6 | ||
|
|
9369dc3031 |
No files matched your search
@@ -28,3 +28,4 @@ app/src/main/jniLibs/arm64-v8a/libproot-loader.so
|
||||
!keystore/testkey.p12
|
||||
droiddeck-release-key/
|
||||
password.txt
|
||||
__pycache__/
|
||||
@@ -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
@@ -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}'
|
||||
@@ -16,6 +16,17 @@ library list, before anything is published.
|
||||
3.16.29: realtime-priority Vulkan queues on request (`GAMESCOPE_FORCE_VULKAN_REALTIME=1`)
|
||||
without CAP_SYS_NICE, which proot can never have; and the gamepad-driven cursor sprite following
|
||||
the X pointer that XTest moves (it sat frozen).
|
||||
- `0110-wayland-backend-touch.patch` - this app: the nested Wayland backend bound only the host's
|
||||
pointer and keyboard, so a finger on the phone's screen never reached Steam. It now binds
|
||||
`wl_touch` too and hands each finger to wlserver's touch path (`wlserver_touchdown` / `motion` /
|
||||
`up`) - the one a Steam Deck's touchscreen drives - so what a touch does follows the client's
|
||||
touch mode (Steam's Big Picture sets Passthrough: a real touch, rows scroll under a finger).
|
||||
Finger ids are offset by one, since the nested pointer already moves wlserver's touch 0.
|
||||
- `0111-wayland-pointer-warps-in-passthrough.patch` - this app: the nested pointer's motion goes to
|
||||
wlserver as touch 0, and in Passthrough (Big Picture's touch mode) a motion for a touch that is not
|
||||
down moves nothing - so in the app's touchpad mode the Steam client saw no hover and a click landed
|
||||
wherever the pointer had last been. The motion now always warps the real pointer as well
|
||||
(`bAlwaysWarpCursor`), which the other touch modes did already.
|
||||
|
||||
Sixteen more of Armada's patches are DRM/lease/HDR-on-KMS work for a native display, which this
|
||||
app's Wayland-hosted gamescope never reaches, or need a newer gamescope than the runtime has.
|
||||
@@ -0,0 +1,121 @@
|
||||
diff --git a/src/Backends/WaylandBackend.cpp b/src/Backends/WaylandBackend.cpp
|
||||
index d74268b..bf72267 100644
|
||||
--- a/src/Backends/WaylandBackend.cpp
|
||||
+++ b/src/Backends/WaylandBackend.cpp
|
||||
@@ -601,6 +601,17 @@ namespace gamescope
|
||||
void Wayland_Keyboard_RepeatInfo( wl_keyboard *pKeyboard, int32_t nRate, int32_t nDelay );
|
||||
static const wl_keyboard_listener s_KeyboardListener;
|
||||
|
||||
+ // Touch from the host compositor (DroidDeck): each finger goes to wlserver's touch path,
|
||||
+ // the one a Steam Deck's touchscreen drives, instead of being dropped.
|
||||
+ void Wayland_Touch_Down( wl_touch *pTouch, uint32_t uSerial, uint32_t uTime, wl_surface *pSurface, int32_t nId, wl_fixed_t fX, wl_fixed_t fY );
|
||||
+ void Wayland_Touch_Up( wl_touch *pTouch, uint32_t uSerial, uint32_t uTime, int32_t nId );
|
||||
+ void Wayland_Touch_Motion( wl_touch *pTouch, uint32_t uTime, int32_t nId, wl_fixed_t fX, wl_fixed_t fY );
|
||||
+ void Wayland_Touch_Frame( wl_touch *pTouch );
|
||||
+ void Wayland_Touch_Cancel( wl_touch *pTouch );
|
||||
+ static const wl_touch_listener s_TouchListener;
|
||||
+ bool TouchToOutput( wl_surface *pSurface, wl_fixed_t fX, wl_fixed_t fY, double *pflX, double *pflY );
|
||||
+ std::unordered_map<int32_t, wl_surface *> m_TouchSurfaces;
|
||||
+
|
||||
void Wayland_RelativePointer_RelativeMotion( zwp_relative_pointer_v1 *pRelativePointer, uint32_t uTimeHi, uint32_t uTimeLo, wl_fixed_t fDx, wl_fixed_t fDy, wl_fixed_t fDxUnaccel, wl_fixed_t fDyUnaccel );
|
||||
static const zwp_relative_pointer_v1_listener s_RelativePointerListener;
|
||||
};
|
||||
@@ -636,6 +647,16 @@ namespace gamescope
|
||||
.modifiers = WAYLAND_USERDATA_TO_THIS( CWaylandInputThread, Wayland_Keyboard_Modifiers ),
|
||||
.repeat_info = WAYLAND_USERDATA_TO_THIS( CWaylandInputThread, Wayland_Keyboard_RepeatInfo ),
|
||||
};
|
||||
+ const wl_touch_listener CWaylandInputThread::s_TouchListener =
|
||||
+ {
|
||||
+ .down = WAYLAND_USERDATA_TO_THIS( CWaylandInputThread, Wayland_Touch_Down ),
|
||||
+ .up = WAYLAND_USERDATA_TO_THIS( CWaylandInputThread, Wayland_Touch_Up ),
|
||||
+ .motion = WAYLAND_USERDATA_TO_THIS( CWaylandInputThread, Wayland_Touch_Motion ),
|
||||
+ .frame = WAYLAND_USERDATA_TO_THIS( CWaylandInputThread, Wayland_Touch_Frame ),
|
||||
+ .cancel = WAYLAND_USERDATA_TO_THIS( CWaylandInputThread, Wayland_Touch_Cancel ),
|
||||
+ .shape = WAYLAND_NULL(),
|
||||
+ .orientation = WAYLAND_NULL(),
|
||||
+ };
|
||||
const zwp_relative_pointer_v1_listener CWaylandInputThread::s_RelativePointerListener =
|
||||
{
|
||||
.relative_motion = WAYLAND_USERDATA_TO_THIS( CWaylandInputThread, Wayland_RelativePointer_RelativeMotion ),
|
||||
@@ -3067,6 +3088,81 @@ namespace gamescope
|
||||
wl_keyboard_add_listener( m_pKeyboard, &s_KeyboardListener, this );
|
||||
}
|
||||
}
|
||||
+
|
||||
+ if ( !!( uCapabilities & WL_SEAT_CAPABILITY_TOUCH ) != !!m_pTouch )
|
||||
+ {
|
||||
+ if ( m_pTouch )
|
||||
+ {
|
||||
+ wl_touch_release( m_pTouch );
|
||||
+ m_pTouch = nullptr;
|
||||
+ m_TouchSurfaces.clear();
|
||||
+ }
|
||||
+ else
|
||||
+ {
|
||||
+ m_pTouch = wl_seat_get_touch( m_pSeat );
|
||||
+ wl_touch_add_listener( m_pTouch, &s_TouchListener, this );
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ // Touch
|
||||
+
|
||||
+ bool CWaylandInputThread::TouchToOutput( wl_surface *pSurface, wl_fixed_t fX, wl_fixed_t fY, double *pflX, double *pflY )
|
||||
+ {
|
||||
+ if ( !pSurface || !IsGamescopeToplevel( pSurface ) )
|
||||
+ return false;
|
||||
+ CWaylandPlane *pPlane = (CWaylandPlane *)wl_surface_get_user_data( pSurface );
|
||||
+ if ( !pPlane )
|
||||
+ return false;
|
||||
+ auto oState = pPlane->GetCurrentState();
|
||||
+ if ( !oState || !g_nOutputWidth || !g_nOutputHeight )
|
||||
+ return false;
|
||||
+ uint32_t uScale = oState->uFractionalScale;
|
||||
+ *pflX = ( wl_fixed_to_double( fX ) * uScale / 120.0 + oState->nDestX ) / g_nOutputWidth;
|
||||
+ *pflY = ( wl_fixed_to_double( fY ) * uScale / 120.0 + oState->nDestY ) / g_nOutputHeight;
|
||||
+ return true;
|
||||
+ }
|
||||
+ // Touch ids are offset by one: the nested pointer already moves wlserver's touch 0.
|
||||
+ void CWaylandInputThread::Wayland_Touch_Down( wl_touch *pTouch, uint32_t uSerial, uint32_t uTime, wl_surface *pSurface, int32_t nId, wl_fixed_t fX, wl_fixed_t fY )
|
||||
+ {
|
||||
+ double flX, flY;
|
||||
+ if ( !TouchToOutput( pSurface, fX, fY, &flX, &flY ) )
|
||||
+ return;
|
||||
+ m_TouchSurfaces[ nId ] = pSurface;
|
||||
+ wlserver_lock();
|
||||
+ wlserver_touchdown( flX, flY, nId + 1, ++m_uFakeTimestamp );
|
||||
+ wlserver_unlock();
|
||||
+ }
|
||||
+ void CWaylandInputThread::Wayland_Touch_Up( wl_touch *pTouch, uint32_t uSerial, uint32_t uTime, int32_t nId )
|
||||
+ {
|
||||
+ if ( m_TouchSurfaces.erase( nId ) == 0 )
|
||||
+ return;
|
||||
+ wlserver_lock();
|
||||
+ wlserver_touchup( nId + 1, ++m_uFakeTimestamp );
|
||||
+ wlserver_unlock();
|
||||
+ }
|
||||
+ void CWaylandInputThread::Wayland_Touch_Motion( wl_touch *pTouch, uint32_t uTime, int32_t nId, wl_fixed_t fX, wl_fixed_t fY )
|
||||
+ {
|
||||
+ auto it = m_TouchSurfaces.find( nId );
|
||||
+ if ( it == m_TouchSurfaces.end() )
|
||||
+ return;
|
||||
+ double flX, flY;
|
||||
+ if ( !TouchToOutput( it->second, fX, fY, &flX, &flY ) )
|
||||
+ return;
|
||||
+ wlserver_lock();
|
||||
+ wlserver_touchmotion( flX, flY, nId + 1, ++m_uFakeTimestamp );
|
||||
+ wlserver_unlock();
|
||||
+ }
|
||||
+ void CWaylandInputThread::Wayland_Touch_Frame( wl_touch *pTouch )
|
||||
+ {
|
||||
+ }
|
||||
+ void CWaylandInputThread::Wayland_Touch_Cancel( wl_touch *pTouch )
|
||||
+ {
|
||||
+ wlserver_lock();
|
||||
+ for ( auto &[ nId, pSurface ] : m_TouchSurfaces )
|
||||
+ wlserver_touchup( nId + 1, ++m_uFakeTimestamp );
|
||||
+ wlserver_unlock();
|
||||
+ m_TouchSurfaces.clear();
|
||||
}
|
||||
|
||||
void CWaylandInputThread::Wayland_Seat_Name( wl_seat *pSeat, const char *pName )
|
||||
@@ -0,0 +1,16 @@
|
||||
diff --git a/src/Backends/WaylandBackend.cpp b/src/Backends/WaylandBackend.cpp
|
||||
index bf72267..c400978 100644
|
||||
--- a/src/Backends/WaylandBackend.cpp
|
||||
+++ b/src/Backends/WaylandBackend.cpp
|
||||
@@ -3223,7 +3223,10 @@ namespace gamescope
|
||||
double flY = ( wl_fixed_to_double( fSurfaceY ) * uScale / 120.0 + oState->nDestY ) / g_nOutputHeight;
|
||||
|
||||
wlserver_lock();
|
||||
- wlserver_touchmotion( flX, flY, 0, ++m_uFakeTimestamp );
|
||||
+ // Always move the real pointer too: in Passthrough (what Steam's Big Picture asks for) a
|
||||
+ // touch motion for an id that is not down moves nothing, so hover never reached the
|
||||
+ // client and a click landed wherever the pointer had last been.
|
||||
+ wlserver_touchmotion( flX, flY, 0, ++m_uFakeTimestamp, true );
|
||||
wlserver_unlock();
|
||||
}
|
||||
void CWaylandInputThread::Wayland_Pointer_Button( wl_pointer *pPointer, uint32_t uSerial, uint32_t uTime, uint32_t uButton, uint32_t uState )
|
||||
@@ -1,2 +1,2 @@
|
||||
GAMESCOPE_TAG=gamescope-3.16.29-p1
|
||||
GAMESCOPE_SHA256=522d1835d84e4a64a843e095bbe341f68b10f8467f38c0e72ae4ef20b675b873
|
||||
GAMESCOPE_TAG=gamescope-3.16.29-p3
|
||||
GAMESCOPE_SHA256=566e3b93931f37c30375d440d15ee76452892b842d2dc854cd3c06b926c8451b
|
||||
@@ -440,6 +440,42 @@ BUSCONF
|
||||
# falls through to the host, and the guest shell dies on libreadline before the game is reached.
|
||||
# FEX_ROOTFS names it outright and is read whatever the config situation.
|
||||
[ -d /usr/share/guestos/fex-mesa ] && export FEX_ROOTFS=/usr/share/guestos/fex-mesa
|
||||
# Decky is optional and session scoped: the app's marker opts in, and this supervisor is
|
||||
# reaped with the client session. Native ARM64 runs directly. A legacy x86_64 asset is used
|
||||
# only when its recorded architecture and this guest's existing FEX installation agree.
|
||||
decky_loader=$HOME/homebrew/services/PluginLoader
|
||||
if [ -f "$HOME/.droiddeck-decky-enabled" ] && [ -x "$decky_loader" ]; then
|
||||
decky_arch=$(cat "$HOME/homebrew/services/.droiddeck-decky-arch" 2>/dev/null || echo unknown)
|
||||
if [ "$decky_arch" = arm64 ] || { [ "$decky_arch" = x86_64 ] && [ -x /usr/bin/FEX ] && [ -n "${FEX_ROOTFS:-}" ]; }; then
|
||||
(
|
||||
decky_child=
|
||||
decky_stop() { [ -z "$decky_child" ] || kill "$decky_child" 2>/dev/null; [ -z "$decky_child" ] || wait "$decky_child" 2>/dev/null; exit 0; }
|
||||
trap decky_stop TERM INT HUP
|
||||
decky_exits=0
|
||||
while :; do
|
||||
if [ "$decky_arch" = x86_64 ]; then /usr/bin/FEX "$decky_loader" >>"$HOME/homebrew/services/PluginLoader.log" 2>&1 &
|
||||
else "$decky_loader" >>"$HOME/homebrew/services/PluginLoader.log" 2>&1 &
|
||||
fi
|
||||
decky_child=$!
|
||||
decky_status=0
|
||||
wait "$decky_child" || decky_status=$?
|
||||
decky_child=
|
||||
decky_exits=$((decky_exits + 1))
|
||||
if [ "$decky_exits" -ge 5 ]; then
|
||||
echo "== Decky: stopped after $decky_exits loader exits (last status $decky_status)"
|
||||
break
|
||||
fi
|
||||
decky_delay=$((decky_exits * 3))
|
||||
echo "== Decky: loader exited with status $decky_status; restart $((decky_exits + 1))/5 in ${decky_delay}s"
|
||||
sleep "$decky_delay"
|
||||
done
|
||||
) &
|
||||
decky_supervisor=$!
|
||||
echo "== Decky: session supervisor started ($decky_arch)"
|
||||
else
|
||||
echo "== Decky: installed loader architecture is unsupported by this guest; not starting"
|
||||
fi
|
||||
fi
|
||||
# The client exits 42 when it has updated itself and wants a fresh start (steam.sh does this).
|
||||
# BL_STEAM_UI=desktop = the client's desktop UI (a window, the library as a list), which the
|
||||
# desktop's launchers ask for; gamescope shows one window at a time and the game takes over.
|
||||
@@ -521,10 +557,20 @@ BUSCONF
|
||||
# script's parent, so reap_orphans - right under gamescope, where everything is the client's -
|
||||
# is not for here. The helpers this run started (the cores and redistributables beats, the
|
||||
# system bus and the NetworkManager stand-in) go with the client instead.
|
||||
if [ -n "${decky_supervisor:-}" ]; then
|
||||
kill "$decky_supervisor" 2>/dev/null || true
|
||||
wait "$decky_supervisor" 2>/dev/null || true
|
||||
echo "== Decky: session supervisor stopped"
|
||||
fi
|
||||
_bl_jobs=$(jobs -p)
|
||||
[ -n "$_bl_jobs" ] && kill $_bl_jobs 2>/dev/null
|
||||
echo "== steam: the client on the desktop has exited"
|
||||
else
|
||||
if [ -n "${decky_supervisor:-}" ]; then
|
||||
kill "$decky_supervisor" 2>/dev/null || true
|
||||
wait "$decky_supervisor" 2>/dev/null || true
|
||||
echo "== Decky: session supervisor stopped"
|
||||
fi
|
||||
reap_orphans
|
||||
fi
|
||||
exit "$rc"
|
||||
|
||||
Reference in new issue
Block a user