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.
diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt
index 889b895ac..fb70695b5 100644
--- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt
+++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt
@@ -237,7 +237,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
val needRuntime = com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.installedVersion(this) == null
val needDesktop = intent.getStringExtra(SessionService.EXTRA_MODE) == SessionService.MODE_DESKTOP &&
!com.droiddeck.launcher.runtime.DesktopCatalog.desktopInstalled(this)
- if (needRuntime || needDesktop) installThenStart(needRuntime, needDesktop)
+ val needProton = (intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM) == SessionService.MODE_STEAM &&
+ com.droiddeck.launcher.runtime.DesktopCatalog.protonSeedNeeded(this)
+ if (needRuntime || needDesktop || needProton) installThenStart(needRuntime, needDesktop, needProton)
}
hud = PerfHud(this)
hud.onPresentingWindowChanged = {
@@ -542,17 +544,23 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
* First Play (or Desktop) on a fresh install: the runtime, and for the desktop its package,
* are downloaded and unpacked here, on the loading screen's own line and bar, and the session
* starts when they are in. Nothing else changes.
+ *
+ * The first Steam session also gets Valve's ARM64 Proton here, before the client has ever
+ * started, so the compatibility tool is installed and the default at sign-in with no install
+ * dialog, download or client restart. Not being able to fetch it stops nothing: the session
+ * then asks the client for it after sign-in, as it always has.
*/
- private fun installThenStart(runtime: Boolean, desktop: Boolean) {
+ private fun installThenStart(runtime: Boolean, desktop: Boolean, proton: Boolean = false) {
if (SessionState.stopRequested) return
installingRuntime = true
- SessionState.installing = if (runtime) "runtime" else "desktop"
+ SessionState.installing = if (runtime) "runtime" else if (desktop) "desktop" else "proton"
SessionEvents.transition(
SessionPhase.INSTALLING_RUNTIME,
- if (runtime) "runtime.installing" else "desktop.installing",
+ "${SessionState.installing}.installing",
mapOf("component" to SessionState.installing),
)
- loading.step = if (runtime) "downloading the Linux runtime" else "downloading the desktop"
+ loading.step = if (runtime) "downloading the Linux runtime" else if (desktop) "downloading the desktop"
+ else "downloading Proton Experimental (ARM64)"
loading.percent = -1
Thread({
var failedComponent: String? = null
@@ -567,6 +575,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
problem = installDesktop()
if (problem == null) SessionEvents.record("desktop.ready") else failedComponent = "desktop"
}
+ if (problem == null && proton && !SessionState.stopRequested) {
+ SessionState.installing = "proton"
+ SessionEvents.transition(SessionPhase.INSTALLING_RUNTIME, "proton.installing", mapOf("component" to "proton"))
+ val protonProblem = installProton()
+ if (protonProblem == null) SessionEvents.record("proton.ready")
+ else SessionEvents.record("proton.skipped", mapOf("reason" to protonProblem))
+ }
uiHandler.post {
installingRuntime = false
SessionState.installing = null
@@ -632,6 +647,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
return problem?.let { "The desktop did not install ($it). Check the connection and press Desktop again." }
}
+ /** Null when the ARM64 Proton is in, else why not; the session goes on either way. */
+ private fun installProton(): String? {
+ uiHandler.post { loading.percent = -1; loading.step = "downloading Proton Experimental (ARM64)" }
+ val catalog = com.droiddeck.launcher.runtime.DesktopCatalog
+ val entry = catalog.fetch(catalog.STEAM_SEED_URL)?.firstOrNull { it.id == catalog.PROTON_SEED_ID }
+ ?: return "catalog unreachable"
+ return catalog.install(this, entry, progressFor("Proton Experimental (ARM64)", entry.size / 1_000_000))
+ }
+
override fun surfaceCreated(holder: SurfaceHolder) {
if (installingRuntime || SessionState.stopRequested) return
if (!SessionState.running) {
diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/DesktopCatalog.kt b/app/src/main/java/com/droiddeck/launcher/runtime/DesktopCatalog.kt
index 2de78f99c..cda22eb17 100644
--- a/app/src/main/java/com/droiddeck/launcher/runtime/DesktopCatalog.kt
+++ b/app/src/main/java/com/droiddeck/launcher/runtime/DesktopCatalog.kt
@@ -18,6 +18,12 @@ import java.io.File
object DesktopCatalog {
private const val TAG = "DesktopCatalog"
const val CATALOG_URL = "https://raw.githubusercontent.com/The412Banner/winlator-contents/main/desktop.json"
+ /**
+ * Valve's Proton Experimental (ARM64) with its appmanifest, laid out as the client keeps it.
+ * In a catalog of its own so it never shows among the packages a user picks from.
+ */
+ const val STEAM_SEED_URL = "https://raw.githubusercontent.com/The412Banner/winlator-contents/main/steam-seed.json"
+ const val PROTON_SEED_ID = "proton-arm64"
class Entry(
val id: String, val name: String, val tier: Int, val version: String, val kind: String,
@@ -26,8 +32,8 @@ object DesktopCatalog {
val icon: String, val category: String,
)
- fun fetch(): List? {
- val body = Downloader.downloadString(CATALOG_URL) ?: return null
+ fun fetch(url: String = CATALOG_URL): List? {
+ val body = Downloader.downloadString(url) ?: return null
return try {
val json = JSONObject(body)
val arr = json.getJSONArray("packages")
@@ -57,6 +63,15 @@ object DesktopCatalog {
fun installed(context: Context, id: String): String? =
FileUtils.readString(marker(context, id))?.trim()?.takeIf { it.isNotEmpty() }
+ /**
+ * True until the ARM64 Proton has been placed once. Its appmanifest counts too, so an install
+ * the client fetched itself is never overwritten; after the first placement the client owns it
+ * and updates it like any other app, and a user who removes it is not given it back.
+ */
+ fun protonSeedNeeded(context: Context): Boolean =
+ installed(context, PROTON_SEED_ID) == null &&
+ !File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/steamapps/appmanifest_4427310.acf").isFile
+
// labwc comes with the hosted desktop package itself (its launcher is staged by the app at
// every session, so it cannot tell whether the package is there); SessionFiles uses the same test.
fun desktopInstalled(context: Context): Boolean =
diff --git a/app/src/main/res/drawable-nodpi/logo.png b/app/src/main/res/drawable-nodpi/logo.png
index a9c737a39..f9cb240cc 100644
Binary files a/app/src/main/res/drawable-nodpi/logo.png and b/app/src/main/res/drawable-nodpi/logo.png differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_background.png b/app/src/main/res/mipmap-hdpi/ic_launcher_background.png
index 8538bed39..4d4352381 100644
Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_background.png and b/app/src/main/res/mipmap-hdpi/ic_launcher_background.png differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
index 6998da424..fac0ce159 100644
Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_background.png b/app/src/main/res/mipmap-mdpi/ic_launcher_background.png
index a92ccfafb..adcd0243a 100644
Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_background.png and b/app/src/main/res/mipmap-mdpi/ic_launcher_background.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
index a9a5f33dc..2e204d650 100644
Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png
index 3ba37c9e9..1ef58344b 100644
Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png and b/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
index 98c616ee8..6579c62e6 100644
Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png
index 07f6de5a9..abac9697e 100644
Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
index 35a8ddbbc..cb9f1c443 100644
Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png
index 5ae3008fe..8ab6e6ea4 100644
Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
index 91ae0e397..617b5db63 100644
Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ
diff --git a/artwork/README.md b/artwork/README.md
index 9379fc54f..6b2f7de2e 100644
--- a/artwork/README.md
+++ b/artwork/README.md
@@ -1,5 +1,5 @@
# DroidDeck mark
-`droiddeck-mark.svg` is the editable source for the split-D mark. The blue orb is `#1A9FFF`; its halo and the two separator bars are transparent cutouts. The bars are centered on x=63.97, the endpoint of the curved half in the original Steam Deck symbol, and use the halo's 15.83-unit thickness. The mark is optically centered in its view box with a small rightward offset to balance its asymmetric silhouette.
+`droiddeck-mark.svg` is the editable source for the white split-D mark. The blue orb is `#1A9FFF`; its halo and the two separator bars are transparent cutouts. The bars are centered on x=63.97, the endpoint of the curved half in the original Steam Deck symbol, and use the halo's 15.83-unit thickness. The mark is optically centered in its view box with a small rightward offset to balance its asymmetric silhouette. Place it on black when a background is needed.
-Android currently consumes raster resources for the launcher layers and the Compose logo. The `mipmap-*/ic_launcher_foreground.png` files are density-sized exports of the SVG, with the mark kept inside the adaptive icon safe area. The matching backgrounds are white. `drawable-nodpi/logo.png` composes the same mark on a white circular badge for use on the app's dark surfaces.
+Android currently consumes raster resources for the launcher layers and the Compose logo. The `mipmap-*/ic_launcher_foreground.png` files are density-sized exports of the SVG, with the mark kept inside the adaptive icon safe area. The matching backgrounds are black. `drawable-nodpi/logo.png` composes the same mark on a black circular badge.
diff --git a/artwork/droiddeck-banner-dark.svg b/artwork/droiddeck-banner-dark.svg
new file mode 100644
index 000000000..020d3a7ba
--- /dev/null
+++ b/artwork/droiddeck-banner-dark.svg
@@ -0,0 +1,16 @@
+
\ No newline at end of file
diff --git a/artwork/droiddeck-banner-light.svg b/artwork/droiddeck-banner-light.svg
new file mode 100644
index 000000000..e74565f94
--- /dev/null
+++ b/artwork/droiddeck-banner-light.svg
@@ -0,0 +1,16 @@
+
\ No newline at end of file
diff --git a/artwork/droiddeck-mark.svg b/artwork/droiddeck-mark.svg
index 6b071bde2..d748fc326 100644
--- a/artwork/droiddeck-mark.svg
+++ b/artwork/droiddeck-mark.svg
@@ -8,6 +8,6 @@
-
+
diff --git a/artwork/droiddeck-social-preview.png b/artwork/droiddeck-social-preview.png
new file mode 100644
index 000000000..a7b0962ee
Binary files /dev/null and b/artwork/droiddeck-social-preview.png differ
diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session
index 318e7a0ed..65e6e414e 100755
--- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session
+++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session
@@ -312,18 +312,35 @@ case $mode in
# in yet - so the install dialog only appeared after the user restarted the client by hand.
# With no remembered sign-in, ask again once the sign-in has happened: the client binary
# started a second time with a URL hands it to the running instance and exits.
+ #
+ # The request is also dropped while the client is still in its post-logon compat phase (it
+ # probes every registered tool then): with publicbeta 1790380355 that phase took 27 s and a
+ # request sent 15 s after sign-in vanished (2026-09-26, no dialog; 1790121765 was done in
+ # time). So wait for the client to say the phase is over ("Waiting for compat in post-logon
+ # took" in its console log; at most 90 s if it never does), then ask - and ask again, twice
+ # at most, if no install has started a minute later.
if ! grep -q '"RememberPassword"[[:space:]]*"1"' "$steam_root/config/loginusers.vdf" 2>/dev/null; then
(
+ _bl_acf="$steam_root/steamapps/appmanifest_4427310.acf"
_bl_conn="$steam_root/logs/connection_log.txt"
+ _bl_cons="$steam_root/logs/console_log.txt"
_bl_from=$(stat -c %s "$_bl_conn" 2>/dev/null || echo 0)
+ _bl_cfrom=$(stat -c %s "$_bl_cons" 2>/dev/null || echo 0)
for _ in $(seq 1 180); do
sleep 10
- [ -f "$steam_root/steamapps/appmanifest_4427310.acf" ] && exit 0
+ [ -f "$_bl_acf" ] && exit 0
if tail -c +$((_bl_from + 1)) "$_bl_conn" 2>/dev/null | grep -q "RecvMsgClientLogOnResponse() : processing complete"; then
- sleep 15
- [ -f "$steam_root/steamapps/appmanifest_4427310.acf" ] && exit 0
- step "signed in: asking the client again for the ARM64 Proton"
- "$steam_root/steamrtarm64/steam" steam://install/4427310 >/dev/null 2>&1 || true
+ for _ in $(seq 1 45); do
+ tail -c +$((_bl_cfrom + 1)) "$_bl_cons" 2>/dev/null | grep -q "Waiting for compat in post-logon took" && break
+ sleep 2
+ done
+ for _bl_try in 1 2 3; do
+ sleep 10
+ [ -f "$_bl_acf" ] && exit 0
+ step "signed in: asking the client again for the ARM64 Proton (try $_bl_try of 3)"
+ "$steam_root/steamrtarm64/steam" steam://install/4427310 >/dev/null 2>&1 || true
+ sleep 50
+ done
exit 0
fi
done
diff --git a/tools/linuxfs/preload/opens.c b/tools/linuxfs/preload/opens.c
index 52d52ec4c..4c8b6e165 100644
--- a/tools/linuxfs/preload/opens.c
+++ b/tools/linuxfs/preload/opens.c
@@ -34,6 +34,7 @@
#include
int bl_status_without_tracer(const char *path, int flags) __attribute__((visibility("hidden")));
+int bl_writable_retry(int dirfd, const char *path, int flags) __attribute__((visibility("hidden")));
static int retry_without_noatime(int fd, int flags) {
return fd < 0 && errno == EPERM && (flags & O_NOATIME);
@@ -67,6 +68,8 @@ int open(const char *path, int flags, ...) {
}
fd = real_open(path, flags, mode);
if (retry_without_noatime(fd, flags)) fd = real_open(path, flags & ~O_NOATIME, mode);
+ /* A read-only file the Steam client rewrites (perms.c). */
+ if (fd < 0 && bl_writable_retry(AT_FDCWD, path, flags)) fd = real_open(path, flags & ~O_NOATIME, mode);
return fd;
}
@@ -91,6 +94,7 @@ int openat(int dirfd, const char *path, int flags, ...) {
}
fd = real_openat(dirfd, path, flags, mode);
if (retry_without_noatime(fd, flags)) fd = real_openat(dirfd, path, flags & ~O_NOATIME, mode);
+ if (fd < 0 && bl_writable_retry(dirfd, path, flags)) fd = real_openat(dirfd, path, flags & ~O_NOATIME, mode);
return fd;
}
diff --git a/tools/linuxfs/preload/perms.c b/tools/linuxfs/preload/perms.c
new file mode 100644
index 000000000..96f1540c2
--- /dev/null
+++ b/tools/linuxfs/preload/perms.c
@@ -0,0 +1,82 @@
+/*
+ * Root's way past a read-only file, for the Steam client.
+ *
+ * The client runs as "root" inside the session - the rootfs names the app's own uid root, and
+ * proot (-i) shows it that uid - but the kernel treats it as the ordinary user it is. The client
+ * leaves many content files read-only (r-x------,
+ * ~290 files of Proton Experimental (ARM64) alone), and when an update later rewrites one of them
+ * it opens the file for writing expecting root's permission to override the mode. The kernel
+ * refuses (EACCES), the client reports "Missing file permissions" and cancels the update
+ * (2026-09-26: Proton Experimental (ARM64), "CFileWriter: errno: 13 ... wineopenxr.dll").
+ *
+ * So for the client's own processes, a refused write-open of a file this user owns is answered the
+ * way root's would be: the owner's write bit is added - to the file, or to the directory a new
+ * file is being created in - and the open is tried once more. chmod only succeeds on what the app
+ * owns, so nothing it could not already change is touched. Games, Wine and everything else keep
+ * ordinary permissions: only the processes started from the client's own directories take this.
+ */
+#define _GNU_SOURCE
+#include
+#include
+#include
+#include
+#include
+#include
+
+static int in_steam_dirs(const char *path) {
+ return path != NULL && (strstr(path, "/Steam/steamrtarm64/") != NULL
+ || strstr(path, "/Steam/ubuntu12_64/") != NULL || strstr(path, "/Steam/linuxarm64/") != NULL);
+}
+
+/*
+ * The client and its helpers run from the Steam root's platform directories. The kernel's
+ * /proc/self/exe names proot's loader for every process in the session, so the program's own
+ * name - the path it was started by - is asked as well.
+ */
+static int is_steam_client(void) {
+ static int cached = -1;
+ char exe[PATH_MAX];
+ ssize_t n;
+
+ if (cached >= 0) return cached;
+ n = readlink("/proc/self/exe", exe, sizeof(exe) - 1);
+ if (n > 0) exe[n] = '\0';
+ cached = (n > 0 && in_steam_dirs(exe)) || in_steam_dirs(program_invocation_name);
+ return cached;
+}
+
+static int grant_owner_write(int dirfd, const char *path) {
+ struct stat st;
+
+ if (fstatat(dirfd, path, &st, 0) != 0 || (st.st_mode & S_IWUSR)) return 0;
+ return fchmodat(dirfd, path, (st.st_mode & 07777) | S_IWUSR, 0) == 0;
+}
+
+/*
+ * After an open refused with EACCES: returns 1 when a write bit was added and the open is worth
+ * one more try. errno is left as the open set it.
+ */
+__attribute__((visibility("hidden")))
+int bl_writable_retry(int dirfd, const char *path, int flags) {
+ int saved = errno;
+ int granted = 0;
+ char parent[PATH_MAX];
+ const char *slash;
+
+ if (saved != EACCES || path == NULL || (flags & O_ACCMODE) == O_RDONLY || !is_steam_client()) return 0;
+ if (faccessat(dirfd, path, F_OK, 0) == 0) {
+ granted = grant_owner_write(dirfd, path);
+ } else if (flags & O_CREAT) {
+ /* A new file in a read-only directory. */
+ slash = strrchr(path, '/');
+ if (slash == NULL) {
+ granted = grant_owner_write(dirfd, ".");
+ } else if ((size_t) (slash - path) < sizeof(parent)) {
+ memcpy(parent, path, slash - path);
+ parent[slash - path] = '\0';
+ granted = grant_owner_write(dirfd, parent[0] ? parent : "/");
+ }
+ }
+ errno = saved;
+ return granted;
+}