Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68002f3fa7 | ||
|
|
435f7a1b03 | ||
|
|
1fd8c50225 | ||
|
|
197fc3f163 | ||
|
|
0e0f133496 | ||
|
|
d98c211168 | ||
|
|
614b4d4de4 | ||
|
|
43a19a831a | ||
|
|
0d308af024 | ||
|
|
128f73da8f | ||
|
|
e22a55e949 | ||
|
|
67cb005e6c | ||
|
|
73f76f6799 | ||
|
|
961a060461 | ||
|
|
dadbbf841a | ||
|
|
1ce8280371 | ||
|
|
78bdc0e3b5 | ||
|
|
ce5f2f5513 | ||
|
|
0f2792c7ac | ||
|
|
7185b775d4 | ||
|
|
cda6e89c12 | ||
|
|
5f80898c27 | ||
|
|
85560d6471 |
No files matched your search
@@ -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
|
||||
@@ -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")
|
||||
|
||||
@@ -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" \
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,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("", ""))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
**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.
|
||||
|
||||

|
||||
|
||||
### 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.
|
||||
|
||||

|
||||
|
||||
### 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.
|
||||
|
||||

|
||||
|
||||
### 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.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 4.1 MiB |
|
After Width: | Height: | Size: 5.0 MiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 153 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 481 KiB |
@@ -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"
|
||||
|
||||