Compare commits

...
Author SHA1 Message Date
xXJSONDeruloXx 68002f3fa7 build_local: sign with DroidDeck's key when a local .signing.env has it
A local build was signed with the testkey alone, which a release refuses
as an update ("signatures do not match"): testing on a device with a CI
build meant uninstalling it and losing its data first.

With an untracked .signing.env (RELEASE_KEYSTORE, RELEASE_STORE_PASSWORD,
RELEASE_KEY_ALIAS) in the checkout, or in the main checkout for a
worktree, build_local.sh finishes with tools/release/sign-apk.sh as CI's
main builds do, and the apk installs over a release. Without the file
nothing changes. DROIDDECK_SIGNING_ENV points at the file explicitly.
2026-09-28 14:33:15 -04:00
Kurt Himebauch 435f7a1b03 Merge pull request #56 from Droid-Deck/ci/sign-all-variants
ci: sign every package variant on main
2026-09-28 12:46:40 -04:00
xXJSONDeruloXx 1fd8c50225 ci: sign every package variant on main
main's build signed only the standard package, so the pubg, antutu and
ludashi variants existed only after a manual Release APKs run. The build job
now reads the variant list from tools/release/variants.txt, as release.yml
does, and main's signing job signs all of them.
2026-09-28 12:42:56 -04:00
Kurt Himebauch 197fc3f163 Merge pull request #52 from maxjivi05/fix/splash-screen
Reduce Steam startup movie software decoding load
2026-09-28 11:35:25 -04:00
MaxsTechReview 0e0f133496 Reduce Steam startup movie software decoding load 2026-09-28 10:49:25 -04:00
Kurt Himebauch d98c211168 Merge pull request #50 from Droid-Deck/fix/release-blockers
Fix/release blockers
2026-09-28 10:32:44 -04:00
xXJsonDeruloXx 614b4d4de4 chore(release): set version 0.2.0 and remove code comments 2026-09-28 10:32:12 -04:00
Kurt Himebauch 43a19a831a docs(release): show every 0.2.0 section open (#48) 2026-09-28 10:24:24 -04:00
Kurt Himebauch 0d308af024 Merge branch 'main' into fix/release-blockers 2026-09-28 10:22:22 -04:00
The412Banner 128f73da8f Merge pull request #51 from Droid-Deck/fix/child-process-check
Child-process check: read the Developer options toggle too
2026-09-28 09:30:58 -04:00
The412Banner e22a55e949 Child-process check: read the Developer options toggle too
Developer options' "Disable child process restrictions" writes only the
feature-flag property persist.sys.fflag.override.settings_enable_monitor_phantom_procs,
never the global setting. Android (FeatureFlagUtils.isEnabled) uses the global
setting when it is set and that property otherwise, but the check read only the
global setting, so a device switched off in Developer options stayed flagged as
unset however often Check again was pressed.

The check now follows Android's order, and the Setup card no longer says "On"
for a setting that is unset or unreadable.
2026-09-28 09:24:19 -04:00
Kurt Himebauch 67cb005e6c fix(session): stop the session script's own loops when steam exits 2026-09-28 11:52:03 +00:00
Kurt Himebauch 73f76f6799 fix(home): open drawer apps in their own task 2026-09-28 11:52:03 +00:00
Kurt Himebauch 961a060461 fix(session): keep a retried session's first-frame and hud listeners 2026-09-28 11:52:03 +00:00
Kurt Himebauch dadbbf841a fix(session): cancel a session start that is still setting up 2026-09-28 11:52:02 +00:00
Kurt Himebauch 1ce8280371 fix(session): don't launch a cancelled session or end a newer one 2026-09-28 11:52:02 +00:00
Kurt Himebauch 78bdc0e3b5 chore(release): bump to 0.2.1 and check the version at release time 2026-09-28 11:52:02 +00:00
Kurt Himebauch ce5f2f5513 fix(runtime): keep the user's home when a runtime update fails mid-swap 2026-09-28 11:52:02 +00:00
Kurt Himebauch 0f2792c7ac docs: home app and dual-screen demos, trimmed 0.2.0 visuals, README hero (#47) 2026-09-27 22:48:33 -04:00
Kurt Himebauch 7185b775d4 0.2.0 release notes with screenshots, plus launch transition and UI fixes (#46)
* docs(release): draft 0.2.0 beta notes

* docs(release): credit prs and authors in 0.2.0 notes

* docs(release): add #39 and #45 to 0.2.0 notes

* chore: update readme

* fix(ui): cover the whole screen with the capsule wall

The wall was shifted right and then tilted around its own centre, which
left the bottom-left of the home and loading screens bare. Centre it and
size it to the tilted page's bounding box so every corner stays covered.

* feat(ui): flood from the play button into the throbber

Starting a session from a blue button no longer slides the session up
over the front end. The button stretches out on staggered springs until
the page is the ball's blue, the session opens on that same blue with no
window animation, and the blue gathers into the throbber's ball before
the throbber starts. Re-joining a running session, tiles and reduced
motion keep the old rise.

* docs(release): cover everything since 0.1 in the 0.2.0 notes

0.2.0 is the first release from the org, so list what DroidDeck does
as a whole, grouped by area: org pull requests as #N, pull requests
from before the move as The412Banner/DroidDeck#N, and the early work
that predates pull requests by author. CI and build work gets one
short section instead of a line each.

* docs(release): add screenshots and animations to the 0.2.0 notes

Lead with a game running and a few visual highlights, fold the full
list into collapsible sections by area, and shorten every line to the
feature and its credit. Pre-move pull requests read as legacy#N.

* fix(session): give the desktop its own loading hints

The loading screen's hints were written for Steam, so starting the
desktop or an emulator said the first run downloads Steam and that
leaving the app pauses Steam. Desktop and program sessions now rotate
through hints about the desktop instead.

* fix(build): skip incomplete NDKs when picking one locally

An interrupted sdkmanager install leaves a directory like
28.2.13676358.failed under ndk/, which sorted last and was picked
without a toolchain in it. Only consider directories that carry
source.properties.

* docs(release): show launching straight into Big Picture

* docs(release): credit the launch transition to #46
2026-09-27 22:14:23 -04:00
Kurt Himebauch cda6e89c12 fix(session): clear transient UI on pause (#45) 2026-09-27 20:37:58 -04:00
Kurt Himebauch 5f80898c27 Add game file and Proton prefix shortcuts (#39) 2026-09-27 20:37:43 -04:00
The412Banner 85560d6471 Merge pull request #43 from Droid-Deck/fix/session-stragglers
Session stop: kill guest processes proot lost, and cap the session log
2026-09-27 17:19:58 -04:00
45 changed files with 723 additions and 77 deletions

No files matched your search

+7 -1
View File
@@ -34,12 +34,18 @@ jobs:
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
runs-on: ubuntu-24.04
timeout-minutes: 45
outputs:
variants: ${{ steps.variants.outputs.list }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
- name: The packages a signed build ships as
id: variants
run: echo "list=$(awk '!/^#/ && NF { print $1 }' tools/release/variants.txt | jq -R . | jq -sc .)" >> "$GITHUB_OUTPUT"
- name: No private key in the tree
run: |
set -euo pipefail
@@ -427,5 +433,5 @@ jobs:
uses: ./.github/workflows/sign.yml
with:
environment: signing-main
variants: '["standard"]'
variants: ${{ needs.build.outputs.variants }}
secrets: inherit
+3 -2
View File
@@ -79,8 +79,9 @@ jobs:
}
stable=$(package_line "$RUNNER_TEMP/stable/DroidDeck-$stable_tag.apk")
candidate=$(package_line "${apks[0]}")
[[ "$stable" =~ ^com\.droiddeck\.launcher\ [0-9]+$ && "$candidate" == "$stable" ]] || {
echo "::error::PR package/versionCode '$candidate' differs from stable '$stable'"; exit 1;
[[ "$stable" =~ ^com\.droiddeck\.launcher\ [0-9]+$ && "$candidate" =~ ^com\.droiddeck\.launcher\ [0-9]+$ &&
${candidate##* } -ge ${stable##* } ]] || {
echo "::error::PR package/versionCode '$candidate' cannot install over stable '$stable'"; exit 1;
}
current=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER")
+4
View File
@@ -35,6 +35,10 @@ jobs:
run: |
set -euo pipefail
[[ $VERSION =~ ^[0-9A-Za-z._-]+$ ]] || { echo "::error::version '$VERSION': letters, digits, . _ - only"; exit 1; }
name=$(sed -n 's/^ *versionName "\(.*\)"$/\1/p' app/build.gradle)
[[ $VERSION == "$name" || $VERSION == "$name"-* ]] || {
echo "::error::version '$VERSION' does not match versionName '$name' in app/build.gradle: bump versionName and versionCode first"; exit 1;
}
runs=$(gh run list -R "$GITHUB_REPOSITORY" --workflow build.yml --branch main --commit "$GITHUB_SHA" \
--status success --json databaseId,event,headSha -L 20)
id=$(jq -r --arg sha "$GITHUB_SHA" \
+3
View File
@@ -29,3 +29,6 @@ app/src/main/jniLibs/arm64-v8a/libproot-loader.so
droiddeck-release-key/
password.txt
__pycache__/
# Local signing with the release key (tools/build_local.sh); holds a password.
.signing.env
+3 -1
View File
@@ -5,7 +5,9 @@
</picture>
</p>
Run Valve's native ARM64 Steam client on an Adreno Android device. Steam runs in a Linux runtime under proot, with gamescope and an in-app Vulkan compositor. Windows games use Valve's ARM64 Proton and FEX. A desktop with LXQt, Firefox, and emulators is also available.
DroidDeck brings the SteamOS experience to Android: Valve's Steam client in Big Picture on your Adreno handheld, with Windows games through Valve's ARM64 Proton.
<p align="center"><img src="docs/releases/media/0.2.0/launch-into-steam.gif" width="80%" alt="Tapping DroidDeck on the Android home screen and landing in Steam Big Picture"></p>
## Requirements and install
+2 -2
View File
@@ -63,8 +63,8 @@ android {
// file out of its own data directory, and this app's entire job is to exec a rootfs:
// proot, gamescope, the Steam client and everything it launches all live under files/.
targetSdkVersion 28
versionCode 8
versionName "0.1.7"
versionCode 9
versionName "0.2.0"
buildConfigField "String", "BUILD_LABEL", "\"${escapedBuildLabel}\""
ndk {
@@ -97,13 +97,11 @@ object HomeApp {
val intent = Intent(Intent.ACTION_MAIN)
.addCategory(Intent.CATEGORY_LAUNCHER)
.setComponent(ComponentName(app.packageName, app.className))
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
if (displayId == null) {
context.startActivity(intent)
return
}
// Keep the target app's task separate from DroidDeck. Without this, Android can put the
// app on top of the caller's task and move the entire task to the chosen display.
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
val options = ActivityOptions.makeBasic().setLaunchDisplayId(displayId)
context.startActivity(intent, options.toBundle())
}
@@ -24,6 +24,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.toArgb
import com.droiddeck.launcher.gpu.FrameGen
import com.droiddeck.launcher.gpu.TurnipDriver
import com.droiddeck.launcher.gpu.LsfgNative
@@ -83,6 +84,10 @@ class MainActivity : ComponentActivity() {
// The screen's state. Compose redraws whatever reads these when they change.
private var installed by mutableStateOf<String?>(null)
/** A session launch waiting on its button to fill the page (LaunchFlood). */
private var flood by mutableStateOf<PendingFlood?>(null)
private var floodProgress by androidx.compose.runtime.mutableFloatStateOf(0f)
private class PendingFlood(val from: androidx.compose.ui.geometry.Rect, val intent: Intent)
private var ready by mutableStateOf(false)
private var available by mutableStateOf<LinuxRuntimeInstaller.Release?>(null)
private var busy by mutableStateOf(false)
@@ -225,10 +230,28 @@ class MainActivity : ComponentActivity() {
}
}
}
if (intent?.component?.className == SessionActivity::class.java.name) {
if (flood != null) return
// Started by a blue button: it floods the page first. A running session is re-joined
// as before, rising over the front end.
val from = com.droiddeck.launcher.ui.LaunchOrigin.take()
if (from != null && !SessionState.running && com.droiddeck.launcher.ui.Motion.scale != 0f) {
flood = PendingFlood(from, intent)
return
}
com.droiddeck.launcher.ui.LaunchOrigin.flooding = null
}
super.startActivity(intent)
if (intent?.component?.className == SessionActivity::class.java.name) overridePendingTransition(R.anim.session_rise, R.anim.session_hold)
}
/** The page is all blue: the session opens on the same blue, with no animation of its own. */
private fun launchFlooded(f: PendingFlood) {
val signal = com.droiddeck.launcher.ui.Themes.byId(theme).signal
super.startActivity(f.intent.putExtra(com.droiddeck.launcher.ui.EXTRA_FLOOD, signal.toArgb()))
overridePendingTransition(0, 0)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
displayManager = getSystemService(DISPLAY_SERVICE) as DisplayManager
@@ -248,6 +271,7 @@ class MainActivity : ComponentActivity() {
showMapping -> { { MappingHost() } }
else -> null
}
com.droiddeck.launcher.ui.FloodBehind({ floodProgress }) {
FrontEndScreen(
FrontEndState(
installed = installed, ready = ready, available = available?.version,
@@ -320,6 +344,10 @@ class MainActivity : ComponentActivity() {
onPerformance = { refreshCores(); showProtons = false; showComponents = false; showMapping = false; showPerformance = true },
onRoms = { showRoms = true },
onFiles = { startActivity(Intent(this, com.droiddeck.launcher.files.FileManagerActivity::class.java)) },
onBrowseFiles = { dir ->
startActivity(Intent(this, com.droiddeck.launcher.files.FileManagerActivity::class.java)
.putExtra(com.droiddeck.launcher.files.FileManagerActivity.EXTRA_START_DIR, dir.absolutePath))
},
onLogs = {
SessionPrefs.setLogsEnabled(this, !SessionPrefs.logsEnabled(this))
logsEnabled = SessionPrefs.logsEnabled(this)
@@ -417,6 +445,7 @@ class MainActivity : ComponentActivity() {
),
page = page,
)
}
if (showRoms) RomsDialog(
path = romsDir,
onChoose = {
@@ -443,6 +472,7 @@ class MainActivity : ComponentActivity() {
onDismiss = { showRemove = false },
)
if (showCredits) CreditsDialog { showCredits = false }
flood?.let { f -> com.droiddeck.launcher.ui.LaunchFlood(f.from, onProgress = { floodProgress = it }) { launchFlooded(f) } }
}
}
@@ -552,6 +582,10 @@ class MainActivity : ComponentActivity() {
override fun onStop() {
displayManager.unregisterDisplayListener(secondScreenDisplayListener)
// The session covers the page by now; coming back finds it as it was.
flood = null
floodProgress = 0f
com.droiddeck.launcher.ui.LaunchOrigin.flooding = null
super.onStop()
}
@@ -952,7 +986,13 @@ class MainActivity : ComponentActivity() {
// The libraries, off the main thread: manifests and a folder scan.
Thread({
val games = if (ready) Library.steamGames(this) + com.droiddeck.launcher.frontend.AddedGames.scan(this).map { g ->
com.droiddeck.launcher.frontend.AddedGameArt.resolve(this, g).let { art -> Library.SteamGame(g.appId.toInt(), g.name, art.portrait ?: art.header, "added", g.gameId, hero = art.hero ?: art.header) }
com.droiddeck.launcher.frontend.AddedGameArt.resolve(this, g).let { art ->
Library.SteamGame(
g.appId.toInt(), g.name, art.portrait ?: art.header, "added", g.gameId,
hero = art.hero ?: art.header, gameFiles = g.folder,
protonPrefix = Library.protonPrefix(this, g.appId),
)
}
} else emptyList()
val emus = Library.emulators(this) { id -> DesktopCatalog.installed(this, id) != null }
ui.post { steamGames = games; emulatorList = emus }
@@ -97,6 +97,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private var touchMode by mutableStateOf(SessionPrefs.TOUCH_AUTO)
private var cursorPos by mutableStateOf(androidx.compose.ui.geometry.Offset(-100f, -100f))
private var cursorVisible by mutableStateOf(false)
/** The blue the front end flooded to on the way in; the loading screen opens on it (LaunchFlood). */
private var floodColor by mutableStateOf<Int?>(null)
private val cursorHide = Runnable { cursorVisible = false }
/** When the pad or the on-screen controls were last used (uptimeMillis); see [showCursor]. */
private var lastPadInputMs = 0L
@@ -232,7 +234,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
root.pointerIcon = noCursor
surfaceView.pointerIcon = noCursor
loading = LoadingState(this)
loading = LoadingState(this, steam = loadingMode() == SessionService.MODE_STEAM)
if (!SessionState.running) {
val needRuntime = com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.installedVersion(this) == null
val needDesktop = intent.getStringExtra(SessionService.EXTRA_MODE) == SessionService.MODE_DESKTOP &&
@@ -252,19 +254,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
readPrefs()
intent.getIntExtra(com.droiddeck.launcher.ui.EXTRA_FLOOD, 0).takeIf { it != 0 && loading.visible }?.let {
floodColor = it
window.setBackgroundDrawable(android.graphics.drawable.ColorDrawable(it))
}
sessionOverlay = createSessionOverlay()
root.addView(sessionOverlay)
setContentView(root)
handleHomeGuideIntent(intent)
updateOnScreenControls()
WaylandCompositor.setFirstFrameListener {
SessionEvents.firstFrame()
runOnUiThread {
loading.visible = false
hud.start()
}
}
WaylandCompositor.setFirstFrameListener(firstFrameListener)
SessionEvents.markReadyIfPossible()
SessionState.endListener = endListener
// A single Back opens the session menu; two quick presses/swipes send the Steam QAM chord.
@@ -304,6 +304,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
onRetry = { retrySession() },
onShareLogs = { shareCurrentSessionLogs() },
onClose = { finish() },
flood = floodColor?.let { androidx.compose.ui.graphics.Color(it) },
onFlooded = {
floodColor = null
window.setBackgroundDrawable(android.graphics.drawable.ColorDrawable(android.graphics.Color.BLACK))
},
)
// Opening the drawer takes the controller away from the game: release its pad.
androidx.compose.runtime.LaunchedEffect(drawerOpen) {
@@ -1483,6 +1488,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
override fun onPause() {
// Do not carry transient session UI across an app/display transition. In particular, the
// drawer's dim layer can otherwise remain over Steam when this activity returns.
drawerOpen = false
pcKeyboardOpen = false
(getSystemService(INPUT_SERVICE) as? InputManager)?.unregisterInputDeviceListener(deviceListener)
releaseDrawerDirection()
// A button held when the app goes away would stay held in the ring for the whole session.
@@ -1522,6 +1531,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
/** Ours, so a finishing activity never unhooks the one that replaced it. */
private val endListener: (Int) -> Unit = { status -> onSessionEnded(status) }
private val firstFrameListener = Runnable {
SessionEvents.firstFrame()
runOnUiThread {
loading.visible = false
hud.start()
}
}
override fun onDestroy() {
// Deliberately does NOT end the session: this activity can be destroyed while the user is
@@ -1534,7 +1550,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (::hud.isInitialized) hud.stop()
padBridge?.stop()
if (SessionState.endListener === endListener) SessionState.endListener = null
WaylandCompositor.setFirstFrameListener(null)
WaylandCompositor.clearFirstFrameListener(firstFrameListener)
super.onDestroy()
}
@@ -23,18 +23,40 @@ object PhantomProcessLimit {
fun read(resolver: ContentResolver, sdk: Int = Build.VERSION.SDK_INT): PhantomProcessStatus {
if (sdk < Build.VERSION_CODES.S) return PhantomProcessStatus.NOT_APPLICABLE
val value = try {
val global = try {
Settings.Global.getString(resolver, SETTING)
} catch (_: Exception) {
return PhantomProcessStatus.UNREADABLE
}
return when (value?.trim()?.lowercase()) {
return status(global, systemProperty(OVERRIDE_PROPERTY))
}
/**
* The value Android itself acts on, in FeatureFlagUtils.isEnabled's order: the global setting
* when it is set, otherwise the feature-flag override property. Developer options' "Disable
* child process restrictions" writes only the property, so a device switched off there has no
* global setting at all and must not be reported as unset.
*/
fun status(global: String?, override: String?): PhantomProcessStatus {
val value = global?.trim()?.lowercase()?.takeIf { it.isNotEmpty() }
?: override?.trim()?.lowercase()?.takeIf { it.isNotEmpty() }
return when (value) {
"false", "0" -> PhantomProcessStatus.DISABLED
null, "" -> PhantomProcessStatus.UNSET
null -> PhantomProcessStatus.UNSET
else -> PhantomProcessStatus.ENABLED
}
}
private const val OVERRIDE_PROPERTY = "persist.sys.fflag.override.$SETTING"
private fun systemProperty(name: String): String? = try {
Class.forName("android.os.SystemProperties")
.getMethod("get", String::class.java)
.invoke(null, name) as? String
} catch (_: Exception) {
null
}
fun blocksSteam(status: PhantomProcessStatus): Boolean =
status != PhantomProcessStatus.NOT_APPLICABLE && status != PhantomProcessStatus.DISABLED
@@ -11,6 +11,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import com.droiddeck.launcher.ui.DroidDeckTheme
import java.io.File
/** The File Manager as a screen of its own, from the Files tile on the main screen. */
class FileManagerActivity : ComponentActivity() {
@@ -19,10 +20,11 @@ class FileManagerActivity : ComponentActivity() {
if (checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
requestPermissions(arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), 1)
}
val initialDir = intent.getStringExtra(EXTRA_START_DIR)?.let(::File)
setContent {
DroidDeckTheme {
Surface(modifier = Modifier.fillMaxSize().systemBarsPadding(), color = MaterialTheme.colorScheme.background) {
FileManagerScreen()
FileManagerScreen(initialDir = initialDir)
}
}
}
@@ -32,4 +34,8 @@ class FileManagerActivity : ComponentActivity() {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == 1 && grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED) recreate()
}
companion object {
const val EXTRA_START_DIR = "com.droiddeck.launcher.files.START_DIR"
}
}
@@ -21,6 +21,7 @@ object Library {
class SteamGame(
val appId: Int, val name: String, val art: File?, val library: String, val gameId: Long = appId.toLong(),
val hero: File? = null, val lastPlayed: Long = 0L,
val gameFiles: File? = null, val protonPrefix: File? = null,
)
class Rom(val name: String, val hostPath: File, val guestPath: String, val emulatorId: String, val art: File? = null)
class Emulator(val id: String, val name: String, val system: String, val program: String, val installed: Boolean, val games: List<Rom>) {
@@ -66,14 +67,31 @@ object Library {
private val NAME = Regex("^\\s*\"name\"\\s*\"([^\"]*)\"", RegexOption.MULTILINE)
private val STATE = Regex("^\\s*\"StateFlags\"\\s*\"(\\d+)\"", RegexOption.MULTILINE)
private val LAST_PLAYED = Regex("^\\s*\"LastPlayed\"\\s*\"(\\d+)\"", RegexOption.MULTILINE)
private val INSTALL_DIR = Regex("^\\s*\"installdir\"\\s*\"([^\"]*)\"", RegexOption.MULTILINE)
/** Steam's library roots visible to this launcher: its private default plus the selected library. */
private fun steamLibraries(context: Context): List<Pair<File, String>> {
val root = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam")
return listOfNotNull(
root to "internal",
GameStorage.effective(context)?.let { File(it.path) to it.label },
)
}
/** Proton keeps each game's prefix below compatdata/<appid>/pfx in a Steam library. */
fun protonPrefix(context: Context, appId: Long, preferredLibrary: File? = null): File? {
val ids = listOf(appId.toString(), java.lang.Integer.toString(appId.toInt())).distinct()
val roots = (listOfNotNull(preferredLibrary) + steamLibraries(context).map { it.first })
.distinctBy { runCatching { it.canonicalPath }.getOrDefault(it.absolutePath) }
return roots.asSequence()
.flatMap { root -> ids.asSequence().map { id -> File(root, "steamapps/compatdata/$id/pfx") } }
.firstOrNull { it.isDirectory }
}
fun steamGames(context: Context): List<SteamGame> {
val root = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam")
val cache = File(root, "appcache/librarycache")
val libraries = listOfNotNull(
root to "internal",
GameStorage.effective(context)?.let { File(it.path) to it.label },
)
val libraries = steamLibraries(context)
val out = LinkedHashMap<Int, SteamGame>()
for ((library, label) in libraries) {
val steamapps = File(library, "steamapps")
@@ -89,7 +107,17 @@ object Library {
val art = steamCacheImage(cache, appId, STEAM_CAPSULES)
val hero = steamCacheImage(cache, appId, STEAM_HEROES)
val lastPlayed = LAST_PLAYED.find(text)?.groupValues?.get(1)?.toLongOrNull() ?: 0L
out[appId] = SteamGame(appId, name, art, label, hero = hero, lastPlayed = lastPlayed)
// App manifests are Valve KeyValues (VDF) files. installdir is one folder
// below steamapps/common; only expose it when the directory exists and the
// manifest value cannot escape that directory.
val installDir = INSTALL_DIR.find(text)?.groupValues?.get(1)?.trim()
?.takeIf { it.isNotEmpty() && it != "." && it != ".." && '/' !in it && '\\' !in it }
val gameFiles = installDir?.let { File(steamapps, "common/$it").takeIf(File::isDirectory) }
out[appId] = SteamGame(
appId, name, art, label, hero = hero, lastPlayed = lastPlayed,
gameFiles = gameFiles,
protonPrefix = protonPrefix(context, appId.toLong(), library),
)
}
}
return out.values.toList()
@@ -210,6 +210,8 @@ public final class LinuxRuntimeInstaller {
// runtime that isInstalled() would happily launch.
File root = LinuxRuntime.rootDir(context);
File staging = new File(root.getParentFile(), LinuxRuntime.DIR + ".new");
File old = new File(root.getParentFile(), LinuxRuntime.DIR + ".old");
recoverInterruptedSwap(root, staging, old);
FileUtils.delete(staging);
if (!staging.mkdirs()) return false;
if (listener != null) listener.onProgress("Extracting", -1);
@@ -219,7 +221,6 @@ public final class LinuxRuntimeInstaller {
}
FileUtils.writeString(new File(staging, VERSION_FILE), release.version);
File old = new File(root.getParentFile(), LinuxRuntime.DIR + ".old");
FileUtils.delete(old);
if (root.isDirectory() && !root.renameTo(old)) {
FileUtils.delete(staging);
@@ -244,6 +245,8 @@ public final class LinuxRuntimeInstaller {
}
if (!staging.renameTo(root)) {
File keptTo = new File(staging, USER_DATA);
if (keptTo.isDirectory() && old.isDirectory()) keptTo.renameTo(new File(old, USER_DATA));
if (old.isDirectory()) old.renameTo(root);
return false;
}
@@ -257,6 +260,24 @@ public final class LinuxRuntimeInstaller {
}
}
private static void recoverInterruptedSwap(File root, File staging, File old) {
File target = root.isDirectory() ? root : old;
File stagedHome = new File(staging, USER_DATA);
File home = new File(target, USER_DATA);
if (target.isDirectory() && stagedHome.isDirectory() && (!home.exists() || isEmptyDir(home))) {
FileUtils.delete(home);
if (stagedHome.renameTo(home)) Log.w(TAG, "recovered " + USER_DATA + " from an interrupted update");
}
if (!root.isDirectory() && old.isDirectory() && old.renameTo(root)) {
Log.w(TAG, "restored the previous runtime after an interrupted update");
}
}
private static boolean isEmptyDir(File dir) {
String[] names = dir.list();
return dir.isDirectory() && names != null && names.length == 0;
}
/** Removes the runtime; returns false without touching it while an install is running. */
public static boolean uninstall(Context context) {
synchronized (JOB_LOCK) {
@@ -17,7 +17,7 @@ import java.nio.charset.StandardCharsets
* progress, and lifting that out is the difference between "starting the Steam client" for three
* minutes and a percentage that moves.
*/
class LoadingState(context: Context) {
class LoadingState(context: Context, steam: Boolean = true) {
var visible by mutableStateOf(true)
var step by mutableStateOf("Starting the session…")
var percent by mutableIntStateOf(-1)
@@ -27,7 +27,10 @@ class LoadingState(context: Context) {
/** The technical side of an ended session (exit status, log path), under the advice in [step]. */
var endedDetail by mutableStateOf<String?>(null)
private val hints = context.resources.getStringArray(com.droiddeck.launcher.R.array.loading_hints)
// The desktop and the emulators get their own: the Steam ones talk about Steam.
private val hints = context.resources.getStringArray(
if (steam) com.droiddeck.launcher.R.array.loading_hints else com.droiddeck.launcher.R.array.loading_hints_desktop,
)
private val startedAt = SystemClock.elapsedRealtime()
/** Once a second: the clock and the hint. */
@@ -81,7 +81,7 @@ class PerfHud(context: Context) {
fun stop() {
running = false
WaylandCompositor.setGameListener(null)
WaylandCompositor.clearGameListener(listener)
text = ""
}
@@ -57,6 +57,7 @@ import java.util.Locale
*/
class SessionService : Service() {
private val components = java.util.concurrent.CopyOnWriteArrayList<SessionPart>()
private val stopLock = Any()
private var wakeLock: PowerManager.WakeLock? = null
private var wifiLock: WifiManager.WifiLock? = null
private var sessionPid = -1
@@ -84,7 +85,7 @@ class SessionService : Service() {
}
}
/** Counts sessions this service has started; a process exit from an earlier one is ignored. */
private var sessionGen = 0
@Volatile private var sessionGen = 0
override fun onCreate() {
super.onCreate()
@@ -392,6 +393,11 @@ class SessionService : Service() {
networkLink.attach(this)
networkLink.publish()
components.add(networkLink)
if (gen != sessionGen || !SessionState.running) {
Log.i(TAG, "session stopped while it was starting; not launching it")
if (gen == sessionGen) components.clear()
return
}
components.forEach { it.start() }
val line = command.joinToString(" ") {
@@ -402,7 +408,7 @@ class SessionService : Service() {
// by the teardown a second after the new one has started, and its exit used to arrive
// here as "session ended: 137" and end the NEW session. An exit belongs to the session
// that started it.
sessionPid = HostProcess.start(line, hostEnv.asArray(), root, { status ->
val pid = HostProcess.start(line, hostEnv.asArray(), root, { status ->
if (gen != sessionGen) {
Log.i(TAG, "an earlier session's process ended ($status); the current one carries on")
return@start
@@ -410,12 +416,24 @@ class SessionService : Service() {
Log.i(TAG, "session ended: $status")
stopSession(status ?: -1)
}, null)
Log.i(TAG, "session pid $sessionPid, log ${sessionLog.path}")
if (gen == sessionGen && SessionState.running && sessionPid > 1) {
SessionEvents.guestStarted(sessionPid)
} else if (gen == sessionGen && SessionState.running) {
SessionEvents.record("guest.start_failed", mapOf("pid" to sessionPid))
SessionEvents.fail("GUEST_START_FAILED", "The guest process could not be started", sessionPid)
Log.i(TAG, "session pid $pid, log ${sessionLog.path}")
if (gen != sessionGen || !SessionState.running) {
Log.i(TAG, "session stopped while its guest was starting; taking it down")
if (gen == sessionGen) {
launchWatcher?.stopWatching()
launchWatcher = null
components.reversed().forEach { runCatching { it.stop() } }
components.clear()
}
if (pid > 1) Thread({ teardown(pid) }, "session-teardown").start()
return
}
sessionPid = pid
if (pid > 1) {
SessionEvents.guestStarted(pid)
} else {
SessionEvents.record("guest.start_failed", mapOf("pid" to pid))
SessionEvents.fail("GUEST_START_FAILED", "The guest process could not be started", pid)
stopSession(-1)
return
}
@@ -877,8 +895,12 @@ class SessionService : Service() {
}
}
private fun finishSessionStop(status: Int) {
private fun finishSessionStop(status: Int, stoppedGen: Int) {
mainHandler.post {
if (stoppedGen != sessionGen || SessionState.running) {
Log.i(TAG, "session $stoppedGen finished stopping after a new one started")
return@post
}
SessionState.suspended = false
SessionState.guestPid = -1
releaseLocks()
@@ -897,8 +919,11 @@ class SessionService : Service() {
}
private fun stopSession(status: Int) {
if (!SessionState.running) return
SessionState.running = false
synchronized(stopLock) {
if (!SessionState.running) return
SessionState.running = false
}
val stoppedGen = sessionGen
SessionState.stopRequested = false
SessionEvents.record("guest.exited", mapOf("status" to status))
SessionEvents.transition(SessionPhase.STOPPING, "session.stopping", mapOf("status" to status))
@@ -942,10 +967,10 @@ class SessionService : Service() {
auxiliary.forEach { (pid, started) -> teardown(pid, started) }
if (prootPid > 1) teardown(prootPid)
killGuestLeftovers()
finishSessionStop(status)
finishSessionStop(status, stoppedGen)
}, "session-teardown").start()
} else {
finishSessionStop(status)
finishSessionStop(status, stoppedGen)
}
}
if (prootPid > 1) acquireLocks()
@@ -1127,6 +1152,10 @@ class SessionService : Service() {
}
fun stop(context: Context) {
if (!SessionState.running) {
SessionState.stopRequested = true
return
}
context.startService(Intent(context, SessionService::class.java).setAction(ACTION_STOP))
}
@@ -81,10 +81,7 @@ internal fun GamesPage(s: FrontEndState, a: FrontEndActions, selected: String, o
Row(verticalAlignment = Alignment.Bottom) {
GameHero(current, Modifier.weight(1f).heightIn(min = if (narrow) 190.dp else 250.dp)) {
GameHeroCopy(current, if (narrow) 28.sp else 38.sp)
Actions {
PrimaryButton("Launch", enabled = !s.busy, main = true, icon = Icons.Filled.PlayArrow) { a.onSteamGame(current) }
BusyChip(s)
}
GameActions(current, s, a)
}
if (!narrow) Poster(current.art, current.name, Modifier.width(168.dp))
}
@@ -105,10 +102,7 @@ internal fun GamesPage(s: FrontEndState, a: FrontEndActions, selected: String, o
) {
GameHero(current, Modifier.fillMaxWidth().heightIn(min = if (narrow) 170.dp else 200.dp)) {
GameHeroCopy(current, if (narrow) 24.sp else 32.sp)
Actions {
PrimaryButton("Launch", enabled = !s.busy, main = true, icon = Icons.Filled.PlayArrow) { a.onSteamGame(current) }
BusyChip(s)
}
GameActions(current, s, a)
}
SectionTitle("Launch settings", null)
LaunchSettings(s, a, host)
@@ -116,6 +110,21 @@ internal fun GamesPage(s: FrontEndState, a: FrontEndActions, selected: String, o
}
}
@Composable
private fun GameActions(g: Library.SteamGame, s: FrontEndState, a: FrontEndActions) {
Actions {
PrimaryButton("Launch", enabled = !s.busy, main = true, icon = Icons.Filled.PlayArrow) { a.onSteamGame(g) }
g.gameFiles?.takeIf { it.isDirectory }?.let { dir ->
SecondaryButton("Game files", compact = true) { a.onBrowseFiles(dir) }
}
g.protonPrefix?.takeIf { it.isDirectory }?.let { dir ->
SecondaryButton("Proton prefix", compact = true) { a.onBrowseFiles(dir) }
}
BusyChip(s)
}
}
/** When it was last played (or where it is, if never) over its name, then room for Launch. */
@Composable
private fun ColumnScope.GameHeroCopy(g: Library.SteamGame, titleSize: androidx.compose.ui.unit.TextUnit) {
@@ -1,5 +1,7 @@
package com.droiddeck.launcher.ui
import java.io.File
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.ExperimentalComposeUiApi
@@ -134,6 +136,7 @@ class FrontEndActions(
val onPerformance: () -> Unit,
val onRoms: () -> Unit,
val onFiles: () -> Unit,
val onBrowseFiles: (File) -> Unit = {},
val onLogs: () -> Unit,
val onShareLogs: () -> Unit = {},
val onOffline: () -> Unit,
@@ -208,7 +208,12 @@ internal fun SetupPanel(
CheckRow(
if (limitBlocks) CheckState.WARN else CheckState.OK,
"Child-process limit",
if (limitBlocks) "On · Android may close Steam. Turning it off takes a minute" else PhantomProcessLimit.title(s.phantomProcessStatus),
when (s.phantomProcessStatus) {
PhantomProcessStatus.ENABLED -> "On · Android may close Steam. Turning it off takes a minute"
PhantomProcessStatus.UNSET -> "Not set · the ROM default may close Steam. Turning it off takes a minute"
PhantomProcessStatus.UNREADABLE -> "Could not be checked · turning it off takes a minute"
else -> PhantomProcessLimit.title(s.phantomProcessStatus)
},
) {
if (limitBlocks) PrimaryButton(if (showLimitDetails) "Hide" else "Fix it", compact = true) { showLimitDetails = !showLimitDetails }
else if (s.phantomProcessStatus != PhantomProcessStatus.NOT_APPLICABLE) {
@@ -1,5 +1,7 @@
package com.droiddeck.launcher.ui
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import android.provider.Settings
@@ -163,20 +165,26 @@ internal fun PrimaryButton(
val pressed by src.collectIsPressedAsState()
val scale by animateFloatAsState(if (pressed) 0.955f else if (hot) 1.02f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "btnScale")
val lift by animateFloatAsState(if (hot) 14f else 6f, Motion.tw(300), label = "btnLift")
// Where it sits, so a session it starts can grow out of it (LaunchFlood).
val placed = remember { arrayOfNulls<androidx.compose.ui.layout.LayoutCoordinates>(1) }
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier.then(track)
.onGloballyPositioned { placed[0] = it }
// 48dp to touch on a handheld; compact rows keep 44.
.heightIn(min = if (compact) 44.dp else 48.dp)
.then(if (frontFocus != null) Modifier.focusRequester(frontFocus.primary).onFocusChanged { if (it.isFocused) frontFocus.last = FrontFocus.PRIMARY } else Modifier)
.graphicsLayer { scaleX = scale; scaleY = scale; shadowElevation = if (enabled) lift.dp.toPx() else 0f; shape = Shape12; clip = false; ambientShadowColor = pal.signal; spotShadowColor = pal.signal }
.graphicsLayer { alpha = if (LaunchOrigin.flooding === placed) 0f else 1f; scaleX = scale; scaleY = scale; shadowElevation = if (enabled) lift.dp.toPx() else 0f; shape = Shape12; clip = false; ambientShadowColor = pal.signal; spotShadowColor = pal.signal }
.clip(Shape12)
.background(if (enabled) Brush.linearGradient(listOf(colors.primary, pal.primary2)) else Brush.linearGradient(listOf(colors.surfaceVariant, colors.surfaceVariant)))
.shine(hot, 0.45f)
// The grow and shine alone barely show on the light fill: outline it when a
// controller is on it, as the other controls are.
.border(2.dp, if (hot) pal.signal else Color.Transparent, Shape12)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, enabled = enabled, onClick = onClick)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, enabled = enabled) {
placed[0]?.takeIf { it.isAttached }?.let { LaunchOrigin.mark(it.boundsInRoot(), placed) }
onClick()
}
.padding(
start = if (large) 20.dp else if (compact) 10.dp else if (icon != null) 14.dp else 18.dp,
end = if (large) 26.dp else if (compact) 10.dp else 18.dp,
@@ -0,0 +1,174 @@
package com.droiddeck.launcher.ui
import android.os.SystemClock
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
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.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.lerp
import kotlin.math.hypot
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.delay
import kotlinx.coroutines.coroutineScope
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.runtime.snapshotFlow
import androidx.compose.animation.core.spring
// Starting a session from a blue button: the button grows until the page is its blue, and the
// loading screen opens on that blue and gathers it into the ball of the throbber.
/** Carries the flood's colour to the session, which opens on it. */
const val EXTRA_FLOOD = "com.droiddeck.launcher.FLOOD"
/** Where the last pressed blue button sat, so a session started by it can grow out of it. */
internal object LaunchOrigin {
private var bounds: Rect? = null
private var button: Any? = null
private var at = 0L
/** The button a flood is growing out of: it steps aside so only the flood shows. */
var flooding by mutableStateOf<Any?>(null)
fun mark(r: Rect, key: Any) {
bounds = r
button = key
at = SystemClock.uptimeMillis()
}
/** The button behind a launch starting now, if one was pressed just before; it hides for the flood. */
fun take(): Rect? {
val b = bounds?.takeIf { SystemClock.uptimeMillis() - at < 1_500 }
if (b != null) flooding = button
bounds = null
button = null
return b
}
}
/** The page a flood grows over, sinking back a touch as it is covered; [progress] is the flood's. */
@Composable
internal fun FloodBehind(progress: () -> Float, content: @Composable () -> Unit) {
Box(Modifier.fillMaxSize().graphicsLayer { val k = sinkScale(progress()); scaleX = k; scaleY = k }) { content() }
}
private fun sinkScale(progress: Float) = 1f - 0.04f * progress
private val Gather = CubicBezierEasing(0.6f, 0f, 0.15f, 1f)
/**
* The pressed button, [from] in root px, stretching out to cover the page. Each edge rides its own
* loose spring: the ones with furthest to go set off first and quickest, the ones near the page's
* edge lag and then snap after them, so it pulls like something elastic rather than zooming. It
* goes blobby in flight, its gradient settles into the flat blue of the ball, and [onProgress]
* (0 to 1) lets the page behind sink a little. Calls [onCovered] once nothing but blue shows.
* Swallows touches while it runs.
*/
@Composable
internal fun LaunchFlood(from: Rect, onProgress: (Float) -> Unit, onCovered: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
// Left, top, right, bottom: 0 on the button, 1 on the page's edge. Springs overshoot past the
// edge, off the page, so the wobble is felt in the pull and never seen as a shrink.
val edges = remember { List(4) { Animatable(0f) } }
val covered by rememberUpdatedState(onCovered)
val progress by rememberUpdatedState(onProgress)
var page by remember { mutableStateOf(Size.Zero) }
LaunchedEffect(page != Size.Zero) {
if (page == Size.Zero) return@LaunchedEffect
val travel = listOf(from.left, from.top, page.width - from.right, page.height - from.bottom).map { it.coerceAtLeast(0f) }
val far = travel.max().coerceAtLeast(1f)
coroutineScope {
val runs = edges.mapIndexed { i, edge ->
val lead = travel[i] / far
launch {
delay(Motion.ms((180 * (1f - lead)).toInt()).toLong())
edge.animateTo(1f, spring(dampingRatio = 0.48f, stiffness = lerp(110f, 190f, lead))) {
progress(edges.sumOf { it.value.coerceIn(0f, 1f).toDouble() }.toFloat() / 4f)
}
}
}
// Covered once every edge has reached the page's, the first time round.
snapshotFlow { edges.all { it.value >= 0.995f } }.first { it }
covered()
runs.forEach { it.join() }
}
}
Canvas(Modifier.fillMaxSize().onSizeChanged { page = Size(it.width.toFloat(), it.height.toFloat()) }.pointerInput(Unit) { awaitEachGesture { while (true) awaitPointerEvent().changes.forEach { it.consume() } } }) {
val (l, t, r, b) = edges.map { it.value }
val mean = edges.sumOf { it.value.coerceIn(0f, 1f).toDouble() }.toFloat() / 4f
// The button sinks with the page behind (FloodBehind), so the flood leaves from where it is now.
val k = sinkScale(mean)
val cx = size.width / 2f
val cy = size.height / 2f
val left = lerp(cx + (from.left - cx) * k, 0f, l)
val top = lerp(cy + (from.top - cy) * k, 0f, t)
val right = lerp(cx + (from.right - cx) * k, size.width, r)
val bottom = lerp(cy + (from.bottom - cy) * k, size.height, b)
val w = (right - left).coerceAtLeast(0f)
val h = (bottom - top).coerceAtLeast(0f)
// A button's corners at rest, a blob in flight, square once it fills the page.
val blob = kotlin.math.sin(Math.PI * mean).toFloat().coerceAtLeast(0f)
val corner = lerp(12.dp.toPx() * (1f - mean), minOf(w, h) * 0.42f, blob)
// The button's gradient gives way to the ball's flat blue in the first half.
val mix = (mean * 2f).coerceAtMost(1f)
drawRoundRect(
Brush.linearGradient(
listOf(lerp(colors.primary, pal.signal, mix), lerp(pal.primary2, pal.signal, mix)),
start = Offset(left, top), end = Offset(right, bottom),
),
topLeft = Offset(left, top), size = Size(w, h), cornerRadius = CornerRadius(corner),
)
}
}
/**
* The session's first frames: the page in [flood], drawing in to a circle over [ball] (centre and
* radius in root px, once the throbber is laid out) that lands the size of it. Calls [onLanded] then.
*/
@Composable
internal fun FloodGather(flood: Color, ball: Pair<Offset, Float>?, onLanded: () -> Unit) {
val gather = remember { Animatable(0f) }
var started by remember { mutableStateOf(false) }
val landed by rememberUpdatedState(onLanded)
LaunchedEffect(ball != null) {
if (ball == null || started) return@LaunchedEffect
started = true
// A beat of plain blue first, so the change of screen does not read as a cut.
delay(Motion.ms(90).toLong())
gather.animateTo(1f, tween(Motion.ms(640).coerceAtLeast(1), easing = Gather))
landed()
}
Canvas(Modifier.fillMaxSize().pointerInput(Unit) { awaitEachGesture { while (true) awaitPointerEvent().changes.forEach { it.consume() } } }) {
val b = ball
if (b == null) {
drawRect(flood)
return@Canvas
}
val (c, r) = b
val far = maxOf(hypot(c.x, c.y), hypot(size.width - c.x, c.y), hypot(c.x, size.height - c.y), hypot(size.width - c.x, size.height - c.y))
drawCircle(flood, radius = lerp(far, r, gather.value), center = c)
}
}
@@ -4,6 +4,9 @@ import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.runtime.produceState
import androidx.compose.runtime.key
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.runtime.remember
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.drawBehind
@@ -131,8 +134,12 @@ fun LoadingOverlay(
onRetry: (() -> Unit)? = null,
onShareLogs: (() -> Unit)? = null,
onClose: (() -> Unit)? = null,
/** The blue the page opens on when a button flooded to it, gathered into the throbber's ball. */
flood: Color? = null,
onFlooded: () -> Unit = {},
) {
val colors = MaterialTheme.colorScheme
var ball by remember { mutableStateOf<Pair<Offset, Float>?>(null) }
val stages = remember(steam) { loadStages(steam) }
var reached by remember { mutableStateOf(0) }
LaunchedEffect(step, steam) {
@@ -170,7 +177,13 @@ fun LoadingOverlay(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.align(Alignment.Center).verticalScroll(rememberScrollState()).padding(horizontal = 32.dp, vertical = 24.dp),
) {
LogoThrobber(Modifier.width(if (ended) 88.dp else 110.dp), running = !ended)
// Held on the plain D while the flood gathers, and started fresh when it lands.
key(flood == null) {
LogoThrobber(
Modifier.width(if (ended) 88.dp else 110.dp).onGloballyPositioned { ball = throbberBall(it.boundsInRoot()) },
running = !ended && flood == null,
)
}
Spacer(Modifier.height(30.dp))
Text(
if (ended) "The session ended" else title, fontSize = 20.sp, fontWeight = FontWeight.SemiBold,
@@ -195,6 +208,7 @@ fun LoadingOverlay(
modifier = Modifier.align(Alignment.BottomCenter).padding(start = 120.dp, end = 120.dp, bottom = 28.dp),
)
if (!ended && onCancel != null) CancelHint(onCancel, Modifier.align(Alignment.BottomEnd).padding(end = 16.dp, bottom = 16.dp))
if (flood != null) FloodGather(flood, ball, onFlooded)
}
}
@@ -133,8 +133,12 @@ internal fun CapsuleWall(
// Animations off in the system settings: a still wall.
val still = Motion.scale == 0f
BoxWithConstraints(modifier.fillMaxSize()) {
val wallW = maxWidth * 1.3f
val wallH = maxHeight * 2.4f
// Centred on the page and big enough that, once tilted, it still covers every corner.
val tilt = Math.toRadians(13.0)
val cos = kotlin.math.cos(tilt).toFloat()
val sin = kotlin.math.sin(tilt).toFloat()
val wallW = (maxWidth * cos + maxHeight * sin) * 1.1f
val wallH = maxOf(maxHeight * 2.4f, (maxWidth * sin + maxHeight * cos) * 1.1f)
val columns = ((wallW + gap) / (capW + gap)).toInt() + 1
val perRun = ((wallH + gap) / (capH + gap)).toInt() + 1
val run = (capH + gap) * perRun
@@ -142,7 +146,7 @@ internal fun CapsuleWall(
horizontalArrangement = Arrangement.spacedBy(gap),
modifier = Modifier
.wrapContentSize(Alignment.TopStart, unbounded = true)
.offset(x = maxWidth * 0.16f, y = -(wallH - maxHeight) / 2)
.offset(x = -(wallW - maxWidth) / 2, y = -(wallH - maxHeight) / 2)
.requiredSize(wallW, wallH)
.graphicsLayer { rotationZ = -13f },
) {
@@ -64,6 +64,12 @@ internal fun LogoThrobber(modifier: Modifier = Modifier, running: Boolean = true
}
}
/** The ball of a throbber laid out in [bounds]: its centre and its radius, at rest. */
internal fun throbberBall(bounds: androidx.compose.ui.geometry.Rect): Pair<Offset, Float> {
val s = bounds.width / LogoShape.WIDTH
return Offset(bounds.left + (LogoShape.CX - LogoShape.LEFT) * s, bounds.top + LogoShape.CY * s) to LogoShape.BALL * s
}
private const val LOOP_MS = 1900
private val Settle = CubicBezierEasing(0.5f, 0f, 0.3f, 1f)
private val Turn = CubicBezierEasing(0.65f, 0f, 0.35f, 1f)
@@ -26,6 +26,10 @@ public final class WaylandCompositor {
* listener must marshal to the UI thread itself. */
public static void setFirstFrameListener(Runnable r) { firstFrameListener = r; }
public static synchronized void clearFirstFrameListener(Runnable r) {
if (firstFrameListener == r) firstFrameListener = null;
}
/** Invoked from native (banner_on_first_frame) on the first present. */
@SuppressWarnings("unused")
static void onFirstFramePresented() {
@@ -52,6 +56,10 @@ public final class WaylandCompositor {
public static void setGameListener(GameListener l) { gameListener = l; }
public static synchronized void clearGameListener(GameListener l) {
if (gameListener == l) gameListener = null;
}
/** Invoked from native (banner_on_game_surface). */
@SuppressWarnings("unused")
static void onGameSurface(String window, String gpuName) {
+6
View File
@@ -6,6 +6,12 @@
<item>By default, leaving the app pauses Steam; it picks up where it left off when you come back.</item>
<item>Your controller appears as an Xbox 360 pad. Otherwise, use the on-screen controls.</item>
</string-array>
<string-array name="loading_hints_desktop">
<item>First run sets up the desktop. This may take a few minutes.</item>
<item>By default, leaving the app pauses the session; it picks up where it left off when you come back.</item>
<item>Touch works as a touchpad: drag to move the pointer, tap to click.</item>
<item>Your controller appears as an Xbox 360 pad. Otherwise, use the on-screen controls.</item>
</string-array>
<string name="session_channel">Session</string>
<string name="session_channel_description">Shows while a Steam or desktop session is open</string>
<string name="session_notification">Steam is running</string>
@@ -0,0 +1,29 @@
package com.droiddeck.launcher.core
import org.junit.Assert.assertEquals
import org.junit.Test
class PhantomProcessLimitTest {
@Test
fun developerOptionsToggleAloneCountsAsOff() {
assertEquals(PhantomProcessStatus.DISABLED, PhantomProcessLimit.status(null, "false"))
}
@Test
fun globalSettingWinsOverTheProperty() {
assertEquals(PhantomProcessStatus.DISABLED, PhantomProcessLimit.status("false", "true"))
assertEquals(PhantomProcessStatus.ENABLED, PhantomProcessLimit.status("true", "false"))
}
@Test
fun emptyGlobalFallsThroughToTheProperty() {
assertEquals(PhantomProcessStatus.ENABLED, PhantomProcessLimit.status("", "true"))
assertEquals(PhantomProcessStatus.DISABLED, PhantomProcessLimit.status(" ", "0"))
}
@Test
fun neitherSetIsUnset() {
assertEquals(PhantomProcessStatus.UNSET, PhantomProcessLimit.status(null, null))
assertEquals(PhantomProcessStatus.UNSET, PhantomProcessLimit.status("", ""))
}
}
+11 -8
View File
@@ -1,13 +1,17 @@
# DroidDeck boot animation
This source accompanies the single Steam startup movie bundled by the app:
`app/src/main/assets/steam-startup/droiddeck-startup.webm` (1920×1080, pitch black background, VP9 video
`app/src/main/assets/steam-startup/droiddeck-startup.webm` (1280×720 at 30 fps, pitch black background, VP8 video
with Opus audio). `droiddeck-boot.html` is the editable animation; the scripts and WAV below
reproduce the selected render. No alternate MP4 or WebM exports are kept in the repository.
render the animation at the bundled playback profile. No alternate MP4 or WebM exports are kept in the repository.
From the repository root, render the movie with:
python3 artwork/boot-animation-source/render.py 1920 1080 60 app/src/main/assets/steam-startup/droiddeck-startup.webm dark
python3 artwork/boot-animation-source/render.py 1280 720 30 app/src/main/assets/steam-startup/droiddeck-startup.webm dark
The bundled profile reduces decoded pixels per second by 78% compared with 1080p/60.
VP8 also lowers software decode cost while Steam is starting. Keep the bundled movie at
720p/30; higher-resolution exports are intended for sharing.
The app stages that file into Steam's `config/uioverrides/movies` folder before each session. If
Steam still has its built-in startup movie selected, DroidDeck sets this movie as the device's
@@ -27,13 +31,12 @@ it down or step frame by frame (arrow keys).
## Re-render the video
pip install playwright && playwright install chromium # ffmpeg with libvpx-vp9 also required
python3 render.py 1280 800 60 droiddeck-boot-1280x800.webm
python3 render.py 1920 1080 60 droiddeck-boot-1920x1080.webm
pip install playwright && playwright install chromium # ffmpeg with libvpx also required
python3 render.py 1280 720 30 droiddeck-boot-1280x720.webm
python3 render.py 1920 1080 60 droiddeck-boot-1920x1080.mp4 # .mp4 = H.264 for sharing
python3 render.py 1280 800 60 droiddeck-boot-dark-1280x800.webm dark # dark version
python3 render.py 1280 720 30 droiddeck-boot-dark-1280x720.webm dark # dark version
Each frame gets real motion blur (180° shutter, adaptive sub-frames). Output is VP9 for .webm or H.264 for .mp4, both BT.709,
Each frame gets real motion blur (180° shutter, adaptive sub-frames). Output is VP8 for .webm or H.264 for .mp4, both BT.709,
so the orb stays #1A9FFF after compression.
## Sound
+8 -8
View File
@@ -2,11 +2,11 @@
"""Render the DroidDeck boot animation to .webm (Steam) or .mp4 (H.264).
Usage: python3 render.py [WIDTH] [HEIGHT] [FPS] [OUT] [light|dark]
python3 render.py 1280 800 60 droiddeck-boot.webm
python3 render.py 1280 720 30 droiddeck-boot.webm
python3 render.py 1920 1080 60 droiddeck-boot.mp4
python3 render.py 1280 800 60 droiddeck-boot-dark.webm dark
python3 render.py 1280 720 30 droiddeck-boot-dark.webm dark
Needs: pip install playwright && playwright install chromium, plus ffmpeg with libvpx-vp9.
Needs: pip install playwright && playwright install chromium, plus ffmpeg with libvpx.
The animation is drawn by droiddeck-boot.html (same folder); each frame gets real
motion blur (180-degree shutter, up to 48 sub-frames when things move fast).
"""
@@ -15,8 +15,8 @@ from pathlib import Path
from playwright.sync_api import sync_playwright
W = int(sys.argv[1]) if len(sys.argv) > 1 else 1280
H = int(sys.argv[2]) if len(sys.argv) > 2 else 800
FPS = int(sys.argv[3]) if len(sys.argv) > 3 else 60
H = int(sys.argv[2]) if len(sys.argv) > 2 else 720
FPS = int(sys.argv[3]) if len(sys.argv) > 3 else 30
OUT = sys.argv[4] if len(sys.argv) > 4 else f"droiddeck-boot-{W}x{H}.webm"
THEME = sys.argv[5] if len(sys.argv) > 5 else "light" # light | dark
ONLY = [int(x) for x in os.environ.get("FRAMES", "").split(",") if x] # debug: render some frames only
@@ -54,9 +54,9 @@ cmd += ["-vf", "scale=out_color_matrix=bt709:out_range=tv:flags=accurate_rnd+ful
if OUT.lower().endswith(".mp4"): # H.264 for sharing, previews and anything that won't play webm
cmd += ["-c:v", "libx264", "-preset", "slow", "-crf", "15", "-profile:v", "high", "-tune", "animation",
"-g", str(FPS * 2), "-movflags", "+faststart"]
else: # VP9 webm for Steam's startup movie
cmd += ["-c:v", "libvpx-vp9", "-b:v", "0", "-crf", "18", "-deadline", "good", "-cpu-used", "2",
"-row-mt", "1", "-g", str(FPS * 2)]
else: # VP8 keeps software decoding inexpensive during Steam startup
cmd += ["-c:v", "libvpx", "-b:v", "2M", "-crf", "10", "-deadline", "good", "-cpu-used", "2",
"-g", str(FPS * 2)]
cmd += COLOR + ([] if SOUND.exists() else ["-an"]) + [OUT]
subprocess.run(cmd, check=True)
if not os.environ.get("FRAMEDIR"):
+172
View File
@@ -0,0 +1,172 @@
# DroidDeck 0.2.0 (beta)
**Valve's native ARM64 Steam client on your Android handheld.** Windows games run through Valve's ARM64 Proton and FEX, and an optional Linux desktop brings emulators along.
![Tapping DroidDeck on the Android home screen and landing in Steam Big Picture](https://raw.githubusercontent.com/Droid-Deck/DroidDeck/main/docs/releases/media/0.2.0/launch-into-steam.gif)
**Needs:** Android 9+, Adreno 730 or newer (or 8xx), ~3 GB free for the runtime (+1.1 GB for Desktop & apps).
This is the first release from the [Droid-Deck](https://github.com/Droid-Deck) org, so it covers everything DroidDeck does, not just what changed since 0.1.7. `legacy#N` links go to pull requests in [The412Banner/DroidDeck](https://github.com/The412Banner/DroidDeck), from before the move.
---
## Highlights
### Steam, running natively
The real Steam client in Big Picture, fetched from Valve on your device. Turn on **Run Steam when DroidDeck starts** and the app icon takes you straight in, as above.
### A launcher made for handhelds
Your Steam library as a capsule wall, and a Games tab with per-game settings.
<p>
<img src="https://raw.githubusercontent.com/Droid-Deck/DroidDeck/main/docs/releases/media/0.2.0/launcher-steam.jpg" width="49%" alt="Steam tab with the capsule wall">
<img src="https://raw.githubusercontent.com/Droid-Deck/DroidDeck/main/docs/releases/media/0.2.0/launcher-games.jpg" width="49%" alt="Games tab with a game's launch settings">
</p>
### Your Android home screen
Set DroidDeck as the Home app and your Android apps live in the launcher too, and in the session menu, so you can open one without leaving your game.
![Android apps listed in the session menu over Steam](https://raw.githubusercontent.com/Droid-Deck/DroidDeck/main/docs/releases/media/0.2.0/drawer-android-apps.jpg)
### Made for two screens
On a dual-screen handheld, launch an Android app on the second screen while Steam keeps the first, or turn the second screen into a keyboard and trackpad, or a Linux terminal.
<p>
<img src="https://raw.githubusercontent.com/Droid-Deck/DroidDeck/main/docs/releases/media/0.2.0/dual-screen-app.gif" width="32%" alt="Launching Geometry Wars 3 on the second screen from the session menu">
<img src="https://raw.githubusercontent.com/Droid-Deck/DroidDeck/main/docs/releases/media/0.2.0/second-screen-trackpad.gif" width="32%" alt="Second screen as a trackpad and keyboard">
<img src="https://raw.githubusercontent.com/Droid-Deck/DroidDeck/main/docs/releases/media/0.2.0/second-screen-terminal.jpg" width="32%" alt="Second screen as a Linux terminal under Big Picture">
</p>
### A session menu over any game
Press Back mid-game for display, controls, components and session settings, paged with LB/RB.
![Paging through the session menu's tabs](https://raw.githubusercontent.com/Droid-Deck/DroidDeck/main/docs/releases/media/0.2.0/session-drawer.gif)
### Swap FEX, DXVK and VKD3D-Proton per Proton build
Download from the nightlies or import a `.wcp`, and see what the running game is using.
![Components manager](https://raw.githubusercontent.com/Droid-Deck/DroidDeck/main/docs/releases/media/0.2.0/components.jpg)
### A Linux desktop, with emulators one tap away
LXQt on the GPU, plus RPCS3, ARMSX2, Dolphin, DuckStation, melonDS, Cemu, PPSSPP and RetroArch.
<p>
<img src="https://raw.githubusercontent.com/Droid-Deck/DroidDeck/main/docs/releases/media/0.2.0/linux-desktop.jpg" width="49%" alt="LXQt desktop with a file manager and terminal">
<img src="https://raw.githubusercontent.com/Droid-Deck/DroidDeck/main/docs/releases/media/0.2.0/desktop-apps.jpg" width="49%" alt="Desktop tab with emulators to install">
</p>
### On-screen controls you can lay out yourself
Drag every button where your thumbs want it, with adaptive sticks and phone-sized layouts.
![On-screen controller layout editor](https://raw.githubusercontent.com/Droid-Deck/DroidDeck/main/docs/releases/media/0.2.0/controller-editor.jpg)
---
## Everything in 0.2.0
### Steam
- Native ARM64 Steam client under gamescope, on DroidDeck's own Vulkan compositor (@The412Banner)
- Valve's ARM64 Proton and FEX, ready at first sign-in (@The412Banner, [legacy#22](https://github.com/The412Banner/DroidDeck/pull/22), #33)
- Start offline after one sign-in (@The412Banner)
- Steam Deck mode: battery and NIS in Quick Access, MangoHud overlay (@The412Banner, [legacy#9](https://github.com/The412Banner/DroidDeck/pull/9), [legacy#16](https://github.com/The412Banner/DroidDeck/pull/16))
- Games from your own folders appear in Steam, with store art (@The412Banner, [legacy#9](https://github.com/The412Banner/DroidDeck/pull/9))
- Components manager for FEX, DXVK and VKD3D-Proton, applied on every launch (@The412Banner, #29)
- GE-Proton and proton-cachyos as extra compatibility tools (@The412Banner, @xXJSONDeruloXx, [legacy#18](https://github.com/The412Banner/DroidDeck/pull/18))
- FEX presets per session, Stability to Extreme + TSO (@The412Banner)
- Decky Loader, with plugins in Big Picture (@xXJSONDeruloXx, #16)
- Steam's Internet settings and connection indicator work (@maxjivi05)
- Steam sees the device as "DroidDeck" (@xXJSONDeruloXx, #15)
- Steam launchers on the Linux desktop (@The412Banner, #10)
- DroidDeck startup movie, and optionally straight into Steam when the app opens (@xXJSONDeruloXx, #25, #27, #36, #38)
### Launcher
- Side rail, Games tab, capsule wall, graphite theme, controller hints, phone layouts (@xXJSONDeruloXx, #13, #35, #41)
- Launch buttons flood into the loading screen (@xXJSONDeruloXx, #46)
- Game files and Proton prefix shortcuts on each game's page (@xXJSONDeruloXx, #39)
- Controller navigation everywhere, remembered across the rail (@The412Banner, [legacy#29](https://github.com/The412Banner/DroidDeck/pull/29), [legacy#34](https://github.com/The412Banner/DroidDeck/pull/34), [legacy#38](https://github.com/The412Banner/DroidDeck/pull/38))
- Built-in file manager, a ROMs folder anywhere (SD cards too), app files in Android's picker (@The412Banner, [legacy#41](https://github.com/The412Banner/DroidDeck/pull/41))
- Themes and the split-D logo (@The412Banner, @xXJSONDeruloXx, [legacy#4](https://github.com/The412Banner/DroidDeck/pull/4), [legacy#37](https://github.com/The412Banner/DroidDeck/pull/37), #34)
- Use DroidDeck as your Android home app, with Android apps in the front end (@xXJSONDeruloXx, @The412Banner, [legacy#17](https://github.com/The412Banner/DroidDeck/pull/17), [legacy#44](https://github.com/The412Banner/DroidDeck/pull/44))
- Build identity and latest-build link in the top bar (@xXJSONDeruloXx, [legacy#24](https://github.com/The412Banner/DroidDeck/pull/24), #26)
### Sessions
- Sessions keep running when you leave the app (@The412Banner)
- Pause policy (Auto, Manual, Never), changeable mid-session, audio recovers after a pause (@xXJSONDeruloXx, [legacy#7](https://github.com/The412Banner/DroidDeck/pull/7), #14)
- Paged session menu with Display, Controls and Settings tabs (@xXJSONDeruloXx, @The412Banner, [legacy#40](https://github.com/The412Banner/DroidDeck/pull/40), #6, #24)
- Back opens the menu; a double press opens Steam's Quick Access (@xXJSONDeruloXx, [legacy#3](https://github.com/The412Banner/DroidDeck/pull/3), [legacy#20](https://github.com/The412Banner/DroidDeck/pull/20))
- First run sets itself up from the loading screen (@The412Banner, [legacy#12](https://github.com/The412Banner/DroidDeck/pull/12), [legacy#15](https://github.com/The412Banner/DroidDeck/pull/15))
- Launch on any connected display; a second screen can be a keyboard, trackpad or terminal (@xXJSONDeruloXx, [legacy#13](https://github.com/The412Banner/DroidDeck/pull/13), [legacy#33](https://github.com/The412Banner/DroidDeck/pull/33))
- Share session logs from the app or the ended-session screen (@xXJSONDeruloXx, @The412Banner, [legacy#11](https://github.com/The412Banner/DroidDeck/pull/11), #17, #41)
### Controls
- Any controller shows up as an Xbox 360 pad; an on-screen pad fills in when none is connected (@The412Banner)
- On-screen controller editor with adaptive sticks (@maxjivi05, #9)
- On-screen sticks that use the letterbox bars, plus Steam and QAM touch buttons (@The412Banner, @xXJSONDeruloXx, [legacy#6](https://github.com/The412Banner/DroidDeck/pull/6))
- Real touch in Big Picture; a desktop touchpad that hovers, clicks, scrolls and two-finger drags (@The412Banner, #10, #22)
- Cursor shapes, hidden while you play on a pad (@The412Banner, #18)
- Soft keyboard and a full PC keyboard (@The412Banner, [legacy#25](https://github.com/The412Banner/DroidDeck/pull/25))
### Display and performance
- Frame generation: built-in Win-FG, or LSFG from your own Lossless Scaling, 2×–4× (@The412Banner)
- Import or download Turnip drivers per mode, from Banners-Turnip and WinNative (@The412Banner)
- Resolution and shape per mode, including "Exactly this panel" for 4:3 and 3:2 screens (@The412Banner, [legacy#14](https://github.com/The412Banner/DroidDeck/pull/14))
- Stretch games to fill; Quake-engine games windowed to fit (@The412Banner, [legacy#32](https://github.com/The412Banner/DroidDeck/pull/32))
- HDR10 on supported panels (experimental) (@The412Banner)
- Runs as a game on Android: sustained performance, fastest panel mode, ADPF hints (@The412Banner)
- Steam's interface stays on the GPU (@maxjivi05)
- Pin Steam and games to chosen CPU cores (@The412Banner)
- Faster proot with WinNative's seccomp and tracing fixes (@maxjivi05, #4, #37)
### Desktop and emulators
- LXQt on labwc, on the GPU with Vulkan by default (@The412Banner, [legacy#25](https://github.com/The412Banner/DroidDeck/pull/25))
- Emulators downloaded on request: RetroArch, PPSSPP, RPCS3, DuckStation, ARMSX2, Dolphin, melonDS, Cemu and more (@The412Banner)
- Emulator games launch full screen from the front end, one tile per disc, with box art (@The412Banner, [legacy#29](https://github.com/The412Banner/DroidDeck/pull/29), [legacy#34](https://github.com/The412Banner/DroidDeck/pull/34), [legacy#38](https://github.com/The412Banner/DroidDeck/pull/38))
- ARMSX2 replaces PCSX2 for PS2: 60 fps where PCSX2 managed 17 (@The412Banner, [legacy#26](https://github.com/The412Banner/DroidDeck/pull/26))
- Dolphin, melonDS and Cemu set up out of the box (@The412Banner, #10, #18, #42)
- Emulator controllers preconfigured, and RPCS3 shader fixes (@The412Banner, [legacy#25](https://github.com/The412Banner/DroidDeck/pull/25))
### Audio
- DirectAudio for games, straight to Android (@The412Banner, [legacy#9](https://github.com/The412Banner/DroidDeck/pull/9), [legacy#11](https://github.com/The412Banner/DroidDeck/pull/11))
- Microphone for voice chat, on by default (@The412Banner, [legacy#11](https://github.com/The412Banner/DroidDeck/pull/11))
### Fixed
- Steam updates failing on read-only files (@The412Banner, #33)
- A dropped finger-up blocking later touches (@The412Banner, #22)
- Stopped sessions leaving stray processes and log spam (@The412Banner, #43)
- Cemu and shadPS4 crashing on the input shim; no cursor on the GPU desktop (@The412Banner, #42)
- Leftovers from crashed or killed sessions cleaned up at the next start (@The412Banner, [legacy#16](https://github.com/The412Banner/DroidDeck/pull/16))
- The menu's dim layer staying over Steam after switching apps (@xXJSONDeruloXx, #45)
- Steam library capsule art (@xXJSONDeruloXx, #12)
- The menu's Desktop button wrapping (@xXJSONDeruloXx, #28)
- Launcher restarting when a controller connects (@xXJSONDeruloXx, #40)
- Runtime installs racing, or stopping when you leave the app (@xXJSONDeruloXx, #40)
- Steam blocked by Android's child-process limit; Setup lifts it over wireless ADB (@xXJSONDeruloXx, #21)
### Security
- SHA-256 checked on every catalog, release and Proton download (@xXJSONDeruloXx, #40)
- Runtime archives can't link outside the rootfs (@xXJSONDeruloXx, #40)
- Logs scrub account and persona names, public IPs and device IDs (@The412Banner, #19)
- Signed with DroidDeck's own release key (@The412Banner, [legacy#41](https://github.com/The412Banner/DroidDeck/pull/41))
### Behind the scenes
- Every PR's APK published to DroidDeck-CI (@maxjivi05, #3), with signed builds gated on review (@xXJSONDeruloXx, #5)
- Local builds matching CI, and a debug-only ADB agent bridge (@xXJSONDeruloXx, [legacy#5](https://github.com/The412Banner/DroidDeck/pull/5), [legacy#35](https://github.com/The412Banner/DroidDeck/pull/35), #7, #20)
- CI hardening, branch protection and copy clean-ups (@The412Banner, @xXJSONDeruloXx, [legacy#8](https://github.com/The412Banner/DroidDeck/pull/8), [legacy#10](https://github.com/The412Banner/DroidDeck/pull/10), [legacy#41](https://github.com/The412Banner/DroidDeck/pull/41), #8)
---
**Built on** WinNative by @maxjivi05 (the gamescope runtime, proot session and input interposer), Winlator by brunodev85 (audio and gamepad model), Bannerlator by @The412Banner (file manager, first sign-in flow), lsfg-vk by PancakeTAS, MangoHud by flightlessmango, and Vauzi's Adreno 710/720/722 device entries.
Not affiliated with Valve. Steam, Proton and Steam Deck are Valve's; the client and Proton are fetched from Valve's servers on your device.
**Contributors:** @The412Banner, @xXJSONDeruloXx, @maxjivi05
Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 481 KiB

+25 -1
View File
@@ -41,7 +41,8 @@ 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)
# Only complete NDKs: an interrupted sdkmanager install leaves a directory without source.properties.
ndk_path=$(find "${sdk_dir}/ndk" -mindepth 2 -maxdepth 2 -name source.properties -print | xargs -n1 dirname | sort -V | tail -1)
ndk_version=${ndk_path##*/}
fi
if [[ -z "${ndk_version}" || ! -d "${sdk_dir}/ndk/${ndk_version}" ]]; then
@@ -308,6 +309,29 @@ grep -qi 'CN=Android, OU=Android, O=Android' <<<"${signature_output}" || {
exit 1
}
# With DroidDeck's own key at hand, sign as CI's main builds are (tools/release/sign-apk.sh), so
# the apk installs over a release instead of being refused as a different signer. The key's
# location and password live in an untracked .signing.env (RELEASE_KEYSTORE, RELEASE_STORE_PASSWORD,
# RELEASE_KEY_ALIAS), looked for in this checkout and then in the main one, which worktrees share.
signing_env=${DROIDDECK_SIGNING_ENV:-}
if [[ -z "${signing_env}" ]]; then
for candidate in "${repo_root}/.signing.env" \
"$(dirname "$(git -C "${repo_root}" rev-parse --path-format=absolute --git-common-dir 2>/dev/null || echo .)")/.signing.env"; do
[[ -f "${candidate}" ]] && { signing_env=${candidate}; break; }
done
fi
if [[ -n "${signing_env}" ]]; then
echo "Signing with DroidDeck's key (${signing_env})"
(
set -a
# shellcheck disable=SC1090
. "${signing_env}"
set +a
BUILD_TOOLS="${build_tools}" "${repo_root}/tools/release/sign-apk.sh" "${apk}" standard "${apk}.release"
)
mv "${apk}.release" "${apk}"
fi
printf 'APK: %s\n' "${apk}"
printf 'SHA-256: '
shasum -a 256 "${apk}" | awk '{print $1}'
@@ -591,6 +591,8 @@ BUSCONF
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
reap_orphans
fi
exit "$rc"