Keep the session alive in the background, rename the package, sign with the testkey, and draw the app a logo

The session now belongs to a foreground service rather than the activity.
Android demotes a process as soon as its last visible activity goes and the
low-memory killer reaps the guest, so leaving Big Picture to answer a message
came back to a dead Steam; the service holds the process at perceptible
priority, a partial wake lock keeps the CPU from dropping the guest's threads,
and a high-performance WiFi lock keeps a backgrounded download from being
throttled to nothing. The notification is silent (IMPORTANCE_LOW), carries a
Stop action, and returns to the session when tapped. Back now puts the app away
and leaves Steam running; a swipe out of recents ends the session.

The compositor moved with it: it is process-wide, started once, and merely
swaps the Surface it presents into as the activity comes and goes.

Package is com.steamdeck.launcher, signed with the AOSP testkey (v1+v2+v3) so
every build installs over the last one, and CI asserts all three schemes.

The launcher icon is our own controller on a purple glow — the spirit of the
Steam tile the client entry uses, none of its geometry.
This commit is contained in:
The412Banner committed 2026-09-19 14:17:25 -04:00
1 parent 4031f512fc
commit 344e70afc6
49 files changed
+977 -615

No files matched your search

+15
View File
@@ -171,6 +171,21 @@ jobs:
[ "$fail" = 0 ] && echo "every NEEDED resolves"
exit $fail
# The apk must stay installable over the last one, and on every Android version: that means
# one stable key and all three signature schemes. Cheap to assert, easy to lose silently.
- name: Check the signature
run: |
set -euo pipefail
APK=$(find app/build/outputs/apk -name '*.apk' | head -1)
APKSIGNER=$(find "$ANDROID_HOME/build-tools" -name apksigner | sort -V | tail -1)
"$APKSIGNER" verify --verbose --print-certs "$APK" | tee /tmp/sig.txt
for scheme in "v1 scheme (JAR signing): true" "v2 scheme (APK Signature Scheme v2): true" \
"v3 scheme (APK Signature Scheme v3): true"; do
grep -qF "$scheme" /tmp/sig.txt || { echo "::error::apk is not signed with $scheme"; exit 1; }
done
grep -qi "CN=Android, OU=Android, O=Android" /tmp/sig.txt \
|| { echo "::error::apk is not signed with the AOSP testkey"; exit 1; }
- uses: actions/upload-artifact@v4
with:
name: steamdeck-apk
+13 -4
View File
@@ -38,15 +38,24 @@ The runtime image is the same one Bannerlator installs, so on a device that alre
790 MB download can be skipped entirely — copy it across with root, once, before the first launch:
```sh
su -c 'cp -a /data/data/com.tencent.ig/files/linuxfs /data/data/com.the412banner.steamdeck/files/ \
&& chown -R $(stat -c %u /data/data/com.the412banner.steamdeck/files) \
/data/data/com.the412banner.steamdeck/files/linuxfs \
&& chcon -R u:object_r:app_data_file:s0 /data/data/com.the412banner.steamdeck/files/linuxfs'
su -c 'cp -a /data/data/com.tencent.ig/files/linuxfs /data/data/com.steamdeck.launcher/files/ \
&& chown -R $(stat -c %u /data/data/com.steamdeck.launcher/files) \
/data/data/com.steamdeck.launcher/files/linuxfs \
&& chcon -R u:object_r:app_data_file:s0 /data/data/com.steamdeck.launcher/files/linuxfs'
```
The two runtimes are independent after that: each app has its own Steam install, its own login and
its own games.
## Background and foreground
A session belongs to a foreground service, not to the activity, so leaving Big Picture does not
end it: the process stays at perceptible priority, a partial wake lock keeps the CPU from dropping
the guest's threads, and a high-performance WiFi lock keeps a backgrounded download from being
throttled to nothing. Back puts the app away and leaves Steam running; the (silent) notification
brings it back. A session ends only when you say so — the notification's **Stop session** action —
or when the app is swiped out of recents.
## Device switches
Files in `/sdcard/Download`, for a device that cannot be reached with a debugger:
+21 -5
View File
@@ -4,14 +4,14 @@ plugins {
apply plugin: 'kotlin-android'
android {
namespace 'com.the412banner.steamdeck'
namespace 'com.steamdeck.launcher'
compileSdk 34
// The NDK is pinned by property so CI can hand in whatever the runner already has rather than
// downloading a second copy; see gradle.properties.
ndkVersion project.findProperty('ndkVersion') ?: '27.0.12077973'
defaultConfig {
applicationId "com.the412banner.steamdeck"
applicationId "com.steamdeck.launcher"
minSdkVersion 26
// 28 is not a leftover. An app on targetSdk 29+ runs in a domain that may not execute a
// file out of its own data directory, and this app's entire job is to exec a rootfs:
@@ -37,15 +37,31 @@ android {
}
}
signingConfigs {
// AOSP testkey ([email protected]), the same key Bannerlator ships with. It is publicly
// known, so it proves nothing about who built the apk — what it buys is ONE stable
// signature: every build installs over the last one instead of asking the user to
// uninstall first. v1 (JAR) + v2 + v3 so every installer and Android version accepts it.
testkey {
storeFile file("${rootDir}/keystore/testkey.p12")
storeType "PKCS12"
storePassword "android"
keyAlias "testkey"
keyPassword "android"
enableV1Signing = true
enableV2Signing = true
enableV3Signing = true
}
}
buildTypes {
debug {
minifyEnabled false
signingConfig signingConfigs.testkey
}
release {
minifyEnabled false
// Signed with the debug key for now: this is a test build handed to a device, not a
// published app, and a release key would have to live in the repo's secrets first.
signingConfig signingConfigs.debug
signingConfig signingConfigs.testkey
}
}
+16
View File
@@ -7,6 +7,11 @@
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- The session keeps running while the app is not on screen: a foreground service holds the
process at perceptible priority, and a high-performance WiFi lock keeps a backgrounded
download from being throttled to nothing by the radio's power-save. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-feature android:name="android.hardware.vulkan.version" android:version="0x401000" android:required="true" />
@@ -43,5 +48,16 @@
android:screenOrientation="sensorLandscape"
android:resizeableActivity="false"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation|uiMode|density" />
<!--
Owns the session — gamescope, the Steam client, audio — so it survives the activity
going away. Without it Android demotes the process as soon as the last visible
activity goes, and the low-memory killer reaps the guest: leaving Big Picture to
answer a message would come back to a dead Steam and a half-finished download.
-->
<service
android:name=".session.SessionService"
android:exported="false"
android:stopWithTask="false" />
</application>
</manifest>
+1 -1
View File
@@ -10,7 +10,7 @@
#include <stdatomic.h>
JNIEXPORT void JNICALL
Java_com_the412banner_steamdeck_input_FakeInputWriter_nativeStoreFence(JNIEnv *env, jclass clazz) {
Java_com_steamdeck_launcher_input_FakeInputWriter_nativeStoreFence(JNIEnv *env, jclass clazz) {
(void)env;
(void)clazz;
atomic_thread_fence(memory_order_release);
@@ -7,20 +7,20 @@
#include "framegen_bridge.h"
JNIEXPORT void JNICALL
Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeSetFrameGenEngine(JNIEnv *env, jclass clazz, jint kind) {
Java_com_steamdeck_launcher_wayland_WaylandCompositor_nativeSetFrameGenEngine(JNIEnv *env, jclass clazz, jint kind) {
(void)env; (void)clazz;
vkp_framegen_set_engine((int)kind);
}
JNIEXPORT void JNICALL
Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeSetFrameGenArmed(JNIEnv *env, jclass clazz,
Java_com_steamdeck_launcher_wayland_WaylandCompositor_nativeSetFrameGenArmed(JNIEnv *env, jclass clazz,
jboolean armed, jint multiplier) {
(void)env; (void)clazz;
vkp_framegen_set_armed(armed ? 1 : 0, (int)multiplier);
}
JNIEXPORT void JNICALL
Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeSetLsfgCachePath(JNIEnv *env, jclass clazz, jstring path) {
Java_com_steamdeck_launcher_wayland_WaylandCompositor_nativeSetLsfgCachePath(JNIEnv *env, jclass clazz, jstring path) {
(void)clazz;
if (!path) { vkp_framegen_set_lsfg_cache_path(NULL); return; }
const char *c = (*env)->GetStringUTFChars(env, path, NULL);
@@ -29,34 +29,34 @@ Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeSetLsfgCachePath
}
JNIEXPORT void JNICALL
Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeSetFrameGenTuning(JNIEnv *env, jclass clazz,
Java_com_steamdeck_launcher_wayland_WaylandCompositor_nativeSetFrameGenTuning(JNIEnv *env, jclass clazz,
jfloat flowScale, jfloat refreshHz) {
(void)env; (void)clazz;
vkp_framegen_set_tuning((float)flowScale, (float)refreshHz);
}
JNIEXPORT void JNICALL
Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeSetWinFgTuning(JNIEnv *env, jclass clazz,
Java_com_steamdeck_launcher_wayland_WaylandCompositor_nativeSetWinFgTuning(JNIEnv *env, jclass clazz,
jint model, jint perfPreset) {
(void)env; (void)clazz;
vkp_framegen_set_winfg_tuning((int)model, (int)perfPreset);
}
JNIEXPORT jint JNICALL
Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeFrameGenProblem(JNIEnv *env, jclass clazz) {
Java_com_steamdeck_launcher_wayland_WaylandCompositor_nativeFrameGenProblem(JNIEnv *env, jclass clazz) {
(void)env; (void)clazz;
return (jint)vkp_framegen_problem();
}
JNIEXPORT jstring JNICALL
Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeFrameGenCapsReason(JNIEnv *env, jclass clazz) {
Java_com_steamdeck_launcher_wayland_WaylandCompositor_nativeFrameGenCapsReason(JNIEnv *env, jclass clazz) {
(void)clazz;
const char *r = vkp_framegen_caps_reason();
return (*env)->NewStringUTF(env, r ? r : "");
}
JNIEXPORT jfloatArray JNICALL
Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeFrameGenStats(JNIEnv *env, jclass clazz) {
Java_com_steamdeck_launcher_wayland_WaylandCompositor_nativeFrameGenStats(JNIEnv *env, jclass clazz) {
(void)clazz;
float st[6] = {0.f, 0.f, 0.f, 0.f, -1.f, -1.f};
vkp_framegen_stats(st);
@@ -52,7 +52,7 @@ JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved) {
g_jvm = vm;
JNIEnv *env;
if ((*vm)->GetEnv(vm, (void **)&env, JNI_VERSION_1_6) == JNI_OK) {
jclass c = (*env)->FindClass(env, "com/the412banner/steamdeck/wayland/WaylandCompositor");
jclass c = (*env)->FindClass(env, "com/steamdeck/launcher/wayland/WaylandCompositor");
if (c) {
g_compositor_cls = (*env)->NewGlobalRef(env, c);
g_on_first_frame = (*env)->GetStaticMethodID(env, g_compositor_cls,
@@ -219,7 +219,7 @@ static void start_thread(void) {
/* Headless start (no output window) — used for bring-up tests. */
JNIEXPORT void JNICALL
Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeStart(JNIEnv *env, jclass clazz,
Java_com_steamdeck_launcher_wayland_WaylandCompositor_nativeStart(JNIEnv *env, jclass clazz,
jstring xdgRuntimeDir) {
set_runtime_dir(env, xdgRuntimeDir);
start_thread();
@@ -236,7 +236,7 @@ static char *dup_jstr(JNIEnv *env, jstring s) {
/* Start with a real output Surface + the container's Turnip driver (adrenotools).
* Frames committed by clients are composited to this Surface via Turnip. */
JNIEXPORT void JNICALL
Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeStartWithSurface(
Java_com_steamdeck_launcher_wayland_WaylandCompositor_nativeStartWithSurface(
JNIEnv *env, jclass clazz, jobject surface, jstring xdgRuntimeDir,
jstring driverPath, jstring libraryName, jstring nativeLibDir) {
set_runtime_dir(env, xdgRuntimeDir);
@@ -256,34 +256,34 @@ Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeStartWithSurface
/* Inject a pointer event from the Android SurfaceView touch listener (UI thread).
* action: 0=down 1=move 2=up; x/y in output space (0..1919, 0..1079). */
JNIEXPORT void JNICALL
Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeSendPointer(
Java_com_steamdeck_launcher_wayland_WaylandCompositor_nativeSendPointer(
JNIEnv *env, jclass clazz, jint action, jint x, jint y) {
banner_wayland_send_pointer(action, x, y);
}
/* Inject a key event. evdev = Linux input keycode (KEY_A=30…); state 1=down 0=up. */
JNIEXPORT void JNICALL
Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeSendKey(
Java_com_steamdeck_launcher_wayland_WaylandCompositor_nativeSendKey(
JNIEnv *env, jclass clazz, jint evdev, jint state) {
banner_wayland_send_key(evdev, state);
}
/* App X-server input in scene (virtual desktop) coordinates; see banner_wayland_send_scene_input. */
JNIEXPORT void JNICALL
Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeSendSceneInput(
Java_com_steamdeck_launcher_wayland_WaylandCompositor_nativeSendSceneInput(
JNIEnv *env, jclass clazz, jint type, jint a, jint b) {
banner_wayland_send_scene_input(type, a, b);
}
/* One screen refresh (Choreographer frame callback, UI thread): the compositor draws once. */
JNIEXPORT void JNICALL
Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeVsync(JNIEnv *env, jclass clazz, jlong frameTimeNanos) {
Java_com_steamdeck_launcher_wayland_WaylandCompositor_nativeVsync(JNIEnv *env, jclass clazz, jlong frameTimeNanos) {
banner_wayland_vsync((int64_t)frameTimeNanos);
}
/* Shortcut launches: don't draw explorer's windows (desktop, taskbar), like X11's unviewable classes. */
JNIEXPORT void JNICALL
Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeSetHideShell(JNIEnv *env, jclass clazz, jboolean hide) {
Java_com_steamdeck_launcher_wayland_WaylandCompositor_nativeSetHideShell(JNIEnv *env, jclass clazz, jboolean hide) {
g_hide_shell = hide ? 1 : 0;
}
@@ -292,7 +292,7 @@ Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeSetHideShell(JNI
* thread, any time: before the compositor starts it is the launch default; afterwards the compositor
* thread flips the state, tells the bound games to rebuild their swapchains for it and redraws. */
JNIEXPORT void JNICALL
Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeSetZeroCopy(JNIEnv *env, jclass clazz, jboolean on) {
Java_com_steamdeck_launcher_wayland_WaylandCompositor_nativeSetZeroCopy(JNIEnv *env, jclass clazz, jboolean on) {
int live = banner_get_display() != NULL;
if (!live) g_zero_copy = on ? 1 : 0; /* read by ahb_swapchain_init before the queue drains */
banner_host_zero_copy(on ? 1 : 0, live);
@@ -302,13 +302,13 @@ Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeSetZeroCopy(JNIE
/* Milliseconds since the compositor last put a game frame on the layer without a copy; -1 = never
* this session. The drawer's status line ("switching..." until the frames arrive / stop). */
JNIEXPORT jint JNICALL
Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeZeroCopyLastFrameAgeMs(JNIEnv *env, jclass clazz) {
Java_com_steamdeck_launcher_wayland_WaylandCompositor_nativeZeroCopyLastFrameAgeMs(JNIEnv *env, jclass clazz) {
return (jint)ahb_swapchain_last_frame_age_ms();
}
/* Zero-copy frames in the last completed 10 s stats window (on_stats_timer), for the drawer's live line. */
JNIEXPORT jint JNICALL
Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeZeroCopyFrames(JNIEnv *env, jclass clazz) {
Java_com_steamdeck_launcher_wayland_WaylandCompositor_nativeZeroCopyFrames(JNIEnv *env, jclass clazz) {
return (jint)g_zero_copy_last;
}
@@ -316,7 +316,7 @@ Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeZeroCopyFrames(J
* the renderer's driver imports it (default on; BANNER_WAYLAND_UBWC=0 = off). Set before the compositor
* starts. */
JNIEXPORT void JNICALL
Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeSetUbwc(JNIEnv *env, jclass clazz, jboolean on) {
Java_com_steamdeck_launcher_wayland_WaylandCompositor_nativeSetUbwc(JNIEnv *env, jclass clazz, jboolean on) {
g_ubwc = on ? 1 : 0;
__android_log_print(ANDROID_LOG_INFO, TAG, "compressed (UBWC) game buffers %s", on ? "on" : "off");
}
@@ -324,7 +324,7 @@ Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeSetUbwc(JNIEnv *
/* Debug: name no DRM device in the dma-buf feedback (BANNER_WAYLAND_NO_RENDER_NODE=1), the way a
* phone that exposes no /dev/dri node to apps does. Set before the compositor starts. */
JNIEXPORT void JNICALL
Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeSetNoRenderNode(JNIEnv *env, jclass clazz, jboolean on) {
Java_com_steamdeck_launcher_wayland_WaylandCompositor_nativeSetNoRenderNode(JNIEnv *env, jclass clazz, jboolean on) {
g_no_render_node = on ? 1 : 0;
if (on) __android_log_print(ANDROID_LOG_INFO, TAG, "debug: advertising no DRM device (main device 0:0)");
}
@@ -334,20 +334,20 @@ Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeSetNoRenderNode(
* without this SurfaceFlinger never sees the game's cadence. 0 = clear the vote. Any thread, any
* time; the compositor applies it with its next layer transaction and logs the change. */
JNIEXPORT void JNICALL
Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeSetLayerFrameRate(JNIEnv *env, jclass clazz, jfloat hz) {
Java_com_steamdeck_launcher_wayland_WaylandCompositor_nativeSetLayerFrameRate(JNIEnv *env, jclass clazz, jfloat hz) {
(void)env; (void)clazz;
sc_layer_set_frame_rate((float)hz);
}
/* The panel's refresh rate (Hz) for the advertised wl_output mode. Set before the compositor starts. */
JNIEXPORT void JNICALL
Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeSetOutputRefreshRate(JNIEnv *env, jclass clazz, jfloat hz) {
Java_com_steamdeck_launcher_wayland_WaylandCompositor_nativeSetOutputRefreshRate(JNIEnv *env, jclass clazz, jfloat hz) {
g_output_refresh_mhz = hz > 1.0f ? (int)(hz * 1000.0f + 0.5f) : 0;
}
/* The container's screen size for the advertised wl_output mode. Set before the compositor starts. */
JNIEXPORT void JNICALL
Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeSetOutputSize(JNIEnv *env, jclass clazz, jint w, jint h) {
Java_com_steamdeck_launcher_wayland_WaylandCompositor_nativeSetOutputSize(JNIEnv *env, jclass clazz, jint w, jint h) {
g_output_w = w > 0 ? w : 0;
g_output_h = h > 0 ? h : 0;
}
@@ -355,7 +355,7 @@ Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeSetOutputSize(JN
/* Fullscreen mode + screen alignment (Container.FULLSCREEN_* / ALIGN_* values): how the scene is
* fitted onto the output. Any thread, any time; the next frame uses it. */
JNIEXPORT void JNICALL
Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeSetScaleMode(JNIEnv *env, jclass clazz, jint mode, jint alignment) {
Java_com_steamdeck_launcher_wayland_WaylandCompositor_nativeSetScaleMode(JNIEnv *env, jclass clazz, jint mode, jint alignment) {
vk_present_set_scale_mode(mode, alignment);
__android_log_print(ANDROID_LOG_INFO, TAG, "scale mode %d alignment %d", mode, alignment);
}
@@ -366,38 +366,38 @@ Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeSetScaleMode(JNI
/* Scaling mode: 0=None 1=Linear 2=Nearest 3=SGSR 4=FSR 5=FSR Fit 6=Sharpen 7=NIS 8=SGSR HQ. */
JNIEXPORT void JNICALL
Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeSetUpscaler(JNIEnv *env, jclass clazz, jint mode) {
Java_com_steamdeck_launcher_wayland_WaylandCompositor_nativeSetUpscaler(JNIEnv *env, jclass clazz, jint mode) {
vkp_effects_set_scaling(mode);
}
/* The upscaler's sharpness slider 0..100 (RCAS lobe / SGSR edge / NIS sharpness). */
JNIEXPORT void JNICALL
Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeSetUpscaleSharpness(JNIEnv *env, jclass clazz, jint sharpness) {
Java_com_steamdeck_launcher_wayland_WaylandCompositor_nativeSetUpscaleSharpness(JNIEnv *env, jclass clazz, jint sharpness) {
vkp_effects_set_upscale_sharpness(sharpness);
}
/* AMD CAS sharpen toggle + level 0..100 (0 = pass off). */
JNIEXPORT void JNICALL
Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeSetCas(JNIEnv *env, jclass clazz, jboolean enabled, jint sharpness) {
Java_com_steamdeck_launcher_wayland_WaylandCompositor_nativeSetCas(JNIEnv *env, jclass clazz, jboolean enabled, jint sharpness) {
vkp_effects_set_cas(enabled ? 1 : 0, sharpness);
}
/* Fake-HDR toggle. */
JNIEXPORT void JNICALL
Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeSetHdr(JNIEnv *env, jclass clazz, jboolean enabled) {
Java_com_steamdeck_launcher_wayland_WaylandCompositor_nativeSetHdr(JNIEnv *env, jclass clazz, jboolean enabled) {
vkp_effects_set_hdr(enabled ? 1 : 0);
}
/* Terminal debanding toggle + strength 0..200 (100 = 1 LSB). */
JNIEXPORT void JNICALL
Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeSetDeband(JNIEnv *env, jclass clazz, jboolean enabled, jint strength) {
Java_com_steamdeck_launcher_wayland_WaylandCompositor_nativeSetDeband(JNIEnv *env, jclass clazz, jboolean enabled, jint strength) {
vkp_effects_set_deband(enabled ? 1 : 0, strength);
}
/* Colour grade (slider units: brightness/contrast -100..100, gamma 0.5..3, saturation 0..200 %) and the
* FXAA / Toon / CRT / NTSC toggles — VulkanRenderer.setScreenEffects. */
JNIEXPORT void JNICALL
Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeSetScreenEffects(JNIEnv *env, jclass clazz, jfloat brightness,
Java_com_steamdeck_launcher_wayland_WaylandCompositor_nativeSetScreenEffects(JNIEnv *env, jclass clazz, jfloat brightness,
jfloat contrast, jfloat gamma, jfloat saturation, jboolean fxaa, jboolean toon, jboolean crt, jboolean ntsc) {
vkp_effects_set_screen(brightness, contrast, gamma, saturation, fxaa ? 1 : 0, toon ? 1 : 0, crt ? 1 : 0, ntsc ? 1 : 0);
}
@@ -406,7 +406,7 @@ Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeSetScreenEffects
* framework (the panel's HDR capability) rather than in the compositor. banner_log() mirrors to
* logcat and appends to the session file when one is open, so this is safe at any point. */
JNIEXPORT void JNICALL
Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeLogDisplay(JNIEnv *env, jclass clazz, jstring message) {
Java_com_steamdeck_launcher_wayland_WaylandCompositor_nativeLogDisplay(JNIEnv *env, jclass clazz, jstring message) {
char *s = dup_jstr(env, message);
if (s) banner_log("display", "%s", s);
free(s);
@@ -415,7 +415,7 @@ Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeLogDisplay(JNIEn
/* The same under the "perf" area: facts the app knows about the session's performance setup (the CPU
* cores the game is pinned to, say). Same safety as nativeLogDisplay. */
JNIEXPORT void JNICALL
Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeLogPerf(JNIEnv *env, jclass clazz, jstring message) {
Java_com_steamdeck_launcher_wayland_WaylandCompositor_nativeLogPerf(JNIEnv *env, jclass clazz, jstring message) {
char *s = dup_jstr(env, message);
if (s) banner_log("perf", "%s", s);
free(s);
@@ -425,7 +425,7 @@ Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeLogPerf(JNIEnv *
/* The opt-in: mode 0 off, 1 BANNER_WAYLAND_HDR=1, 2 =force (testing). Before the compositor starts. */
JNIEXPORT void JNICALL
Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeSetHdrRequest(JNIEnv *env, jclass clazz, jint mode,
Java_com_steamdeck_launcher_wayland_WaylandCompositor_nativeSetHdrRequest(JNIEnv *env, jclass clazz, jint mode,
jstring source, jboolean dxvkHdr, jboolean zeroCopyForced) {
char *s = dup_jstr(env, source);
banner_color_set_request((int)mode, s, dxvkHdr ? 1 : 0, zeroCopyForced ? 1 : 0);
@@ -434,7 +434,7 @@ Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeSetHdrRequest(JN
/* The game's display as android.view.Display reports it (before the start; again on every change). */
JNIEXPORT void JNICALL
Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeSetHdrDisplay(JNIEnv *env, jclass clazz, jint id, jstring name,
Java_com_steamdeck_launcher_wayland_WaylandCompositor_nativeSetHdrDisplay(JNIEnv *env, jclass clazz, jint id, jstring name,
jstring formats, jboolean hdr10, jfloat maxLum, jfloat maxAvg, jfloat minLum, jboolean ratioAvailable,
jfloat ratio, jint api) {
char *n = dup_jstr(env, name), *f = dup_jstr(env, formats);
@@ -445,38 +445,38 @@ Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeSetHdrDisplay(JN
/* One Display.getHdrSdrRatio() reading (listener = from the display's ratio listener). Any thread. */
JNIEXPORT void JNICALL
Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeHdrSdrRatioSample(JNIEnv *env, jclass clazz, jfloat ratio,
Java_com_steamdeck_launcher_wayland_WaylandCompositor_nativeHdrSdrRatioSample(JNIEnv *env, jclass clazz, jfloat ratio,
jboolean listener) {
banner_color_ratio_sample((float)ratio, listener ? 1 : 0);
}
/* ms since an HDR frame last reached a display layer, -1 = never this session. Any thread. */
JNIEXPORT jint JNICALL
Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeHdrLastFrameAgeMs(JNIEnv *env, jclass clazz) {
Java_com_steamdeck_launcher_wayland_WaylandCompositor_nativeHdrLastFrameAgeMs(JNIEnv *env, jclass clazz) {
return (jint)banner_color_last_frame_age_ms();
}
/* -1 = not decided yet, 0 = closed, 1 = open. Any thread. */
JNIEXPORT jint JNICALL
Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeHdrGateState(JNIEnv *env, jclass clazz) {
Java_com_steamdeck_launcher_wayland_WaylandCompositor_nativeHdrGateState(JNIEnv *env, jclass clazz) {
return (jint)banner_color_gate_state();
}
/* The session is ending: the "HDR on screen: ..." summary line. Any thread, once. */
JNIEXPORT void JNICALL
Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeHdrSessionEnd(JNIEnv *env, jclass clazz) {
Java_com_steamdeck_launcher_wayland_WaylandCompositor_nativeHdrSessionEnd(JNIEnv *env, jclass clazz) {
banner_color_session_end();
}
/* HDR frames really on screen right now (the HUD badge). Any thread. */
JNIEXPORT jboolean JNICALL
Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeHdrOnScreen(JNIEnv *env, jclass clazz) {
Java_com_steamdeck_launcher_wayland_WaylandCompositor_nativeHdrOnScreen(JNIEnv *env, jclass clazz) {
return banner_color_hdr_on_screen() ? JNI_TRUE : JNI_FALSE;
}
/* One "color" line in the session log from Java. */
JNIEXPORT void JNICALL
Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeLogColor(JNIEnv *env, jclass clazz, jstring message) {
Java_com_steamdeck_launcher_wayland_WaylandCompositor_nativeLogColor(JNIEnv *env, jclass clazz, jstring message) {
char *s = dup_jstr(env, message);
if (s) banner_log("color", "%s", s);
free(s);
@@ -485,7 +485,7 @@ Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeLogColor(JNIEnv
/* One line in the session log under an area Java names ("gpu", "nvapi"): launch facts the app decides.
* The area is the log's 9-character column; an empty one reads "app". */
JNIEXPORT void JNICALL
Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeLog(JNIEnv *env, jclass clazz, jstring area, jstring message) {
Java_com_steamdeck_launcher_wayland_WaylandCompositor_nativeLog(JNIEnv *env, jclass clazz, jstring area, jstring message) {
char *a = dup_jstr(env, area), *s = dup_jstr(env, message);
if (s) banner_log(a && a[0] ? a : "app", "%s", s);
free(a);
@@ -494,26 +494,26 @@ Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeLog(JNIEnv *env,
/* SDR content's level inside an HDR picture, in nits (default 203). */
JNIEXPORT void JNICALL
Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeSetHdrSdrWhite(JNIEnv *env, jclass clazz, jfloat nits) {
Java_com_steamdeck_launcher_wayland_WaylandCompositor_nativeSetHdrSdrWhite(JNIEnv *env, jclass clazz, jfloat nits) {
banner_color_set_sdr_white((float)nits);
}
/* The drawer's live HDR output switch: on = HDR frames as HDR, off = the same frames tone-mapped to SDR.
* Applied on the compositor thread (logged there, with a redraw). */
JNIEXPORT void JNICALL
Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeSetHdrOutput(JNIEnv *env, jclass clazz, jboolean on) {
Java_com_steamdeck_launcher_wayland_WaylandCompositor_nativeSetHdrOutput(JNIEnv *env, jclass clazz, jboolean on) {
if (banner_get_display()) banner_host_hdr_output(on ? 1 : 0);
else banner_color_set_output(on ? 1 : 0); /* no compositor thread yet: nothing is drawing either */
}
JNIEXPORT jboolean JNICALL
Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeHdrOutput(JNIEnv *env, jclass clazz) {
Java_com_steamdeck_launcher_wayland_WaylandCompositor_nativeHdrOutput(JNIEnv *env, jclass clazz) {
return banner_color_output() ? JNI_TRUE : JNI_FALSE;
}
/* Device evidence for the HDR lines: thermal status + headroom, brightness + mode. */
JNIEXPORT void JNICALL
Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeHdrEnvSample(JNIEnv *env, jclass clazz, jint thermal,
Java_com_steamdeck_launcher_wayland_WaylandCompositor_nativeHdrEnvSample(JNIEnv *env, jclass clazz, jint thermal,
jfloat headroom, jint brightness, jint mode) {
banner_color_env_sample((int)thermal, (float)headroom != (float)headroom ? -1.0f : (float)headroom,
(int)brightness, (int)mode);
@@ -521,18 +521,18 @@ Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeHdrEnvSample(JNI
/* Display.getHighestHdrSdrRatio() (Android 16+), <= 0 = not reported. */
JNIEXPORT void JNICALL
Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeSetHdrHighestRatio(JNIEnv *env, jclass clazz, jfloat ratio) {
Java_com_steamdeck_launcher_wayland_WaylandCompositor_nativeSetHdrHighestRatio(JNIEnv *env, jclass clazz, jfloat ratio) {
banner_color_set_highest_ratio((float)ratio == (float)ratio ? (float)ratio : -1.0f);
}
/* The HDR headroom the screen surface should ask for (HDR10 swapchain frames in the last 1.5 s), 0 = none. */
JNIEXPORT jfloat JNICALL
Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeHdrScreenHeadroom(JNIEnv *env, jclass clazz) {
Java_com_steamdeck_launcher_wayland_WaylandCompositor_nativeHdrScreenHeadroom(JNIEnv *env, jclass clazz) {
return (jfloat)banner_color_screen_headroom(NULL, 0);
}
JNIEXPORT jstring JNICALL
Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeHdrScreenHeadroomWhy(JNIEnv *env, jclass clazz) {
Java_com_steamdeck_launcher_wayland_WaylandCompositor_nativeHdrScreenHeadroomWhy(JNIEnv *env, jclass clazz) {
char why[200];
banner_color_screen_headroom(why, sizeof(why));
return (*env)->NewStringUTF(env, why);
@@ -540,25 +540,25 @@ Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeHdrScreenHeadroo
/* The app's screen-surface request, for the no-headroom lines: > 0 asked, 0 cleared, -1 not possible. */
JNIEXPORT void JNICALL
Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeHdrNoteHeadroomRequest(JNIEnv *env, jclass clazz, jfloat ratio) {
Java_com_steamdeck_launcher_wayland_WaylandCompositor_nativeHdrNoteHeadroomRequest(JNIEnv *env, jclass clazz, jfloat ratio) {
banner_color_note_headroom_request((float)ratio);
}
/* 0 none, 1 HDR frames on screen with headroom, 2 HDR frames on screen without headroom for 5 s+. */
JNIEXPORT jint JNICALL
Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeHdrState(JNIEnv *env, jclass clazz) {
Java_com_steamdeck_launcher_wayland_WaylandCompositor_nativeHdrState(JNIEnv *env, jclass clazz) {
return (jint)banner_color_hdr_state();
}
/* An HDR game's frames were shown tone-mapped to SDR in the last 1.5 s (the drawer's status line). */
JNIEXPORT jboolean JNICALL
Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeHdrToneMappedOnScreen(JNIEnv *env, jclass clazz) {
Java_com_steamdeck_launcher_wayland_WaylandCompositor_nativeHdrToneMappedOnScreen(JNIEnv *env, jclass clazz) {
return banner_color_tonemapped_on_screen() ? JNI_TRUE : JNI_FALSE;
}
/* The Look the controls currently match (null = Custom) — only named in the session log. */
JNIEXPORT void JNICALL
Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeSetLookName(JNIEnv *env, jclass clazz, jstring name) {
Java_com_steamdeck_launcher_wayland_WaylandCompositor_nativeSetLookName(JNIEnv *env, jclass clazz, jstring name) {
char *s = dup_jstr(env, name);
vkp_effects_set_look(s);
free(s);
@@ -566,14 +566,14 @@ Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeSetLookName(JNIE
/* The in-game FPS limiter: frames per second, 0 = unlimited. */
JNIEXPORT void JNICALL
Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeSetFpsLimit(JNIEnv *env, jclass clazz, jint fps) {
Java_com_steamdeck_launcher_wayland_WaylandCompositor_nativeSetFpsLimit(JNIEnv *env, jclass clazz, jint fps) {
g_fps_limit = fps > 0 ? fps : 0;
__android_log_print(ANDROID_LOG_INFO, TAG, "fps limit %d", fps);
}
/* Swap/clear the output window (e.g. SurfaceView recreated/destroyed). */
JNIEXPORT void JNICALL
Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeSetSurface(
Java_com_steamdeck_launcher_wayland_WaylandCompositor_nativeSetSurface(
JNIEnv *env, jclass clazz, jobject surface) {
if (surface) {
vk_present_set_window(ANativeWindow_fromSurface(env, surface));
@@ -599,7 +599,7 @@ static char *dup_bytes(JNIEnv *env, jbyteArray arr, int *len) {
/* Android's clipboard text becomes the guest's selection (empty/null = clear). */
JNIEXPORT void JNICALL
Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeSetClipboardText(JNIEnv *env, jclass clazz, jbyteArray utf8) {
Java_com_steamdeck_launcher_wayland_WaylandCompositor_nativeSetClipboardText(JNIEnv *env, jclass clazz, jbyteArray utf8) {
int len;
char *buf = dup_bytes(env, utf8, &len);
banner_host_clipboard_text(buf ? buf : "", len);
@@ -608,7 +608,7 @@ Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeSetClipboardText
/* Soft-keyboard text committed to the program accepting text input. */
JNIEXPORT void JNICALL
Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeTextInputCommit(JNIEnv *env, jclass clazz, jbyteArray utf8) {
Java_com_steamdeck_launcher_wayland_WaylandCompositor_nativeTextInputCommit(JNIEnv *env, jclass clazz, jbyteArray utf8) {
int len;
char *buf = dup_bytes(env, utf8, &len);
if (buf && len) banner_host_text_commit(buf, len);
@@ -617,7 +617,7 @@ Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeTextInputCommit(
/* Composing (pre-edit) text; cursorBegin/cursorEnd are character indexes into it, -1 = end. */
JNIEXPORT void JNICALL
Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeTextInputPreedit(JNIEnv *env, jclass clazz, jbyteArray utf8,
Java_com_steamdeck_launcher_wayland_WaylandCompositor_nativeTextInputPreedit(JNIEnv *env, jclass clazz, jbyteArray utf8,
jint cursorBegin, jint cursorEnd) {
int len;
char *buf = dup_bytes(env, utf8, &len);
@@ -627,6 +627,6 @@ Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeTextInputPreedit
/* The IME deleted characters around the caret. */
JNIEXPORT void JNICALL
Java_com_the412banner_steamdeck_wayland_WaylandCompositor_nativeTextInputDelete(JNIEnv *env, jclass clazz, jint before, jint after) {
Java_com_steamdeck_launcher_wayland_WaylandCompositor_nativeTextInputDelete(JNIEnv *env, jclass clazz, jint before, jint after) {
banner_host_text_delete(before, after);
}
@@ -1,4 +1,4 @@
package com.the412banner.steamdeck
package com.steamdeck.launcher
import android.Manifest
import android.app.Activity
@@ -13,9 +13,9 @@ import android.view.View
import android.widget.Button
import android.widget.ProgressBar
import android.widget.TextView
import com.the412banner.steamdeck.core.FileUtils
import com.the412banner.steamdeck.runtime.LinuxRuntime
import com.the412banner.steamdeck.runtime.LinuxRuntimeInstaller
import com.steamdeck.launcher.core.FileUtils
import com.steamdeck.launcher.runtime.LinuxRuntime
import com.steamdeck.launcher.runtime.LinuxRuntimeInstaller
/**
* The whole app outside a session: is the runtime installed, is there a newer one, and one button
@@ -0,0 +1,279 @@
package com.steamdeck.launcher
import android.app.Activity
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.KeyEvent
import android.view.MotionEvent
import android.view.SurfaceHolder
import android.view.SurfaceView
import android.view.View
import android.view.WindowManager
import android.widget.FrameLayout
import android.widget.TextView
import com.steamdeck.launcher.core.FileUtils
import com.steamdeck.launcher.gpu.TurnipDriver
import com.steamdeck.launcher.input.EvdevKeys
import com.steamdeck.launcher.input.PadBridge
import com.steamdeck.launcher.runtime.LinuxRuntime
import com.steamdeck.launcher.session.SessionService
import com.steamdeck.launcher.session.SessionState
import com.steamdeck.launcher.wayland.CompositorHost
import com.steamdeck.launcher.wayland.WaylandCompositor
import java.io.File
/**
* The session's screen: our Wayland compositor presenting onto this activity's Surface, and the
* input that reaches it. The session itself — gamescope, the Steam client, audio — belongs to
* [SessionService] and keeps running when this activity does not exist, which is what lets the
* user leave Big Picture for another app and come back to it still signed in and still
* downloading.
*
* So there are only three jobs here: hold a Surface under the compositor, forward input, and show
* the session's milestones until its first frame arrives.
*/
class SessionActivity : Activity(), SurfaceHolder.Callback {
private lateinit var surfaceView: SurfaceView
private lateinit var statusView: TextView
private var padBridge: PadBridge? = null
private var watching = true
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
goFullscreen()
if (!LinuxRuntime.isInstalled(this)) {
Log.e(TAG, "the Linux runtime is not installed")
finish()
return
}
val root = FrameLayout(this)
surfaceView = SurfaceView(this)
surfaceView.holder.addCallback(this)
root.addView(surfaceView)
// Until the client's first frame arrives there is nothing on screen for a minute or more
// on a first run, so the session's own milestones are shown instead of a black panel.
statusView = TextView(this).apply {
setPadding(48, 48, 48, 48)
setTextColor(0xFFDDDDDD.toInt())
textSize = 14f
text = getString(R.string.session_starting)
// A session already running is past its milestones; do not cover its picture.
visibility = if (SessionState.running) View.GONE else View.VISIBLE
}
root.addView(statusView)
setContentView(root)
padBridge = PadBridge(File(LinuxRuntime.sessionRoot(this), "dev/input"))
WaylandCompositor.setFirstFrameListener { runOnUiThread { statusView.visibility = View.GONE } }
SessionState.endListener = { status -> onSessionEnded(status) }
watchSessionLog()
}
// ── Compositor ──────────────────────────────────────────────────────────────────────────
override fun surfaceCreated(holder: SurfaceHolder) {
val runtimeDir = File(filesDir, ".wayland-rt").apply { mkdirs() }
// The compositor hands this keymap to wl_keyboard clients, which is how the guest reads
// the evdev codes we inject.
FileUtils.copyAsset(this, "wayland/keymap.xkb", File(runtimeDir, "keymap.xkb"))
// Turnip, not the system Adreno driver: importing the dma-bufs gamescope commits needs
// VK_EXT_image_drm_format_modifier, which the system driver does not implement.
val turnip = TurnipDriver(this)
val driverId = if (CompositorHost.isStarted) null else turnip.install()
val size = outputSize()
SessionState.outputSize = size
SessionState.refreshHz = refreshHz()
val first = CompositorHost.startOrAttach(
holder.surface, runtimeDir.path,
driverId?.let { turnip.driverPath(it) }, driverId?.let { turnip.libraryName(it) },
applicationInfo.nativeLibraryDir, size.first, size.second, refreshHz(),
)
// The service owns everything below the compositor, and starting it is idempotent: coming
// back to a running session just re-attaches the Surface above.
if (first) SessionService.start(this)
}
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {}
override fun surfaceDestroyed(holder: SurfaceHolder) {
CompositorHost.detach()
}
/**
* The size the session renders at. gamescope is told this and scales its output onto whatever
* the panel is, so a 1440p phone can run the client at 1080p without the client knowing.
*/
private fun outputSize(): Pair<Int, Int> {
// The panel, not the window: resources.displayMetrics is what is left after the system
// bars and the cutout are taken out, which sized the first session 1920x968 on a 1080p
// device and had gamescope patch its EDID to match.
val bounds = if (Build.VERSION.SDK_INT >= 30) {
windowManager.maximumWindowMetrics.bounds
} else {
val metrics = android.util.DisplayMetrics()
@Suppress("DEPRECATION")
windowManager.defaultDisplay.getRealMetrics(metrics)
android.graphics.Rect(0, 0, metrics.widthPixels, metrics.heightPixels)
}
var width = maxOf(bounds.width(), bounds.height())
var height = minOf(bounds.width(), bounds.height())
// The client's CEF is the heaviest thing in the session; above 1080p it costs frames for
// nothing anyone can see on a phone panel.
if (height > 1080) {
width = width * 1080 / height
height = 1080
}
// Odd sizes upset the scaler; both dimensions even is what every mode here would be.
return Pair(width and 1.inv(), height and 1.inv())
}
private fun refreshHz(): Float {
val display = if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay
val hz = display?.refreshRate ?: 60f
return if (hz > 1f) hz else 60f
}
// ── Session state ───────────────────────────────────────────────────────────────────────
/** Mirrors the session script's "== STEP" milestones onto the screen while it starts. */
private fun watchSessionLog() {
val handler = Handler(Looper.getMainLooper())
Thread({
var shown = ""
while (watching) {
try {
Thread.sleep(500)
val log = SessionState.logFile ?: continue
val text = FileUtils.readString(log) ?: continue
val step = text.lineSequence().lastOrNull { it.startsWith("== STEP") } ?: continue
val message = step.substringAfter("== STEP ").substringAfter(' ')
if (message != shown) {
shown = message
handler.post { if (statusView.visibility == View.VISIBLE) statusView.text = message }
}
} catch (e: InterruptedException) {
return@Thread
} catch (ignored: Exception) {
}
}
}, "session-log").start()
}
private fun onSessionEnded(status: Int) {
runOnUiThread {
if (isFinishing || isDestroyed) return@runOnUiThread
if (status != 0) {
statusView.visibility = View.VISIBLE
statusView.text = getString(R.string.session_ended, status,
SessionState.logFile?.path ?: "-")
// A moment on screen, so a failure is readable rather than a flash of black.
Handler(Looper.getMainLooper()).postDelayed({ finish() }, 4000)
} else {
finish()
}
}
}
// ── Input ───────────────────────────────────────────────────────────────────────────────
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
if (padBridge?.onKeyEvent(event) == true) return true
// A hardware keyboard, forwarded to the compositor's wl_keyboard. This is how an account
// name and password get typed on the client's first run; the client's own on-screen
// keyboard covers a device without one, driven by touch or the pad.
if (CompositorHost.isStarted && event.device != null && !PadBridge.isFromController(event.device)) {
val down = event.action == KeyEvent.ACTION_DOWN
if (down || event.action == KeyEvent.ACTION_UP) {
// An unmapped key may still be a real one on a foreign layout: its scan code is
// the evdev code the kernel gave Android in the first place.
var evdev = EvdevKeys.fromKeyCode(event.keyCode)
if (evdev < 0 && event.scanCode > 0) evdev = event.scanCode
if (evdev > 0) {
WaylandCompositor.nativeSendKey(evdev, if (down) 1 else 0)
return true
}
}
}
return super.dispatchKeyEvent(event)
}
override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean {
if (padBridge?.onMotionEvent(event) == true) return true
return super.dispatchGenericMotionEvent(event)
}
/**
* Touch as a mouse: where you touch is where the pointer goes, and a tap is a left click.
* The compositor's pointer space is a fixed 1920x1080 whatever the output size, so the view's
* coordinates are scaled into it.
*/
override fun onTouchEvent(event: MotionEvent): Boolean {
val width = surfaceView.width.takeIf { it > 0 } ?: return false
val height = surfaceView.height.takeIf { it > 0 } ?: return false
val action = when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> 0
MotionEvent.ACTION_MOVE -> 1
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> 2
else -> return false
}
val x = (event.x / width * 1920f).toInt().coerceIn(0, 1919)
val y = (event.y / height * 1080f).toInt().coerceIn(0, 1079)
WaylandCompositor.nativeSendPointer(action, x, y)
return true
}
// ── Lifecycle ───────────────────────────────────────────────────────────────────────────
/**
* Back leaves the session running and puts the app behind whatever the user wants next; the
* notification brings it back. Ending a session is done deliberately — from the
* notification's Stop action, or by swiping the app out of recents.
*/
@Suppress("DEPRECATION")
override fun onBackPressed() {
moveTaskToBack(true)
}
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
if (hasFocus) goFullscreen()
}
private fun goFullscreen() {
@Suppress("DEPRECATION")
window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
or View.SYSTEM_UI_FLAG_FULLSCREEN
or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION)
if (Build.VERSION.SDK_INT >= 28) {
window.attributes = window.attributes.apply {
layoutInDisplayCutoutMode =
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
}
}
}
override fun onDestroy() {
// Deliberately does NOT end the session: this activity can be destroyed while the user is
// in another app, and the whole point of the service is that Steam survives that.
watching = false
padBridge?.stop()
SessionState.endListener = null
WaylandCompositor.setFirstFrameListener(null)
super.onDestroy()
}
companion object {
private const val TAG = "SessionActivity"
}
}
@@ -1,13 +1,13 @@
package com.the412banner.steamdeck.audio;
package com.steamdeck.launcher.audio;
import android.content.Context;
import android.os.Process;
import android.util.Log;
import com.the412banner.steamdeck.core.FileUtils;
import com.the412banner.steamdeck.core.EnvironmentComponent;
import com.the412banner.steamdeck.core.ProcessHelper;
import com.the412banner.steamdeck.core.TarZst;
import com.steamdeck.launcher.core.FileUtils;
import com.steamdeck.launcher.core.EnvironmentComponent;
import com.steamdeck.launcher.core.ProcessHelper;
import com.steamdeck.launcher.core.TarZst;
import java.io.File;
import java.io.InputStream;
@@ -1,4 +1,4 @@
package com.the412banner.steamdeck.core;
package com.steamdeck.launcher.core;
/** One-argument callback, kept so the ported runtime classes read as they did in Bannerlator. */
public interface Callback<T> {
@@ -1,4 +1,4 @@
package com.the412banner.steamdeck.core;
package com.steamdeck.launcher.core;
import android.util.Log;
@@ -1,4 +1,4 @@
package com.the412banner.steamdeck.core;
package com.steamdeck.launcher.core;
import java.util.LinkedHashMap;
import java.util.Map;
@@ -1,4 +1,4 @@
package com.the412banner.steamdeck.core;
package com.steamdeck.launcher.core;
import android.content.Context;
@@ -1,4 +1,4 @@
package com.the412banner.steamdeck.core;
package com.steamdeck.launcher.core;
import android.content.Context;
import android.system.ErrnoException;
@@ -1,4 +1,4 @@
package com.the412banner.steamdeck.core;
package com.steamdeck.launcher.core;
import android.util.Log;
@@ -1,4 +1,4 @@
package com.the412banner.steamdeck.core;
package com.steamdeck.launcher.core;
import android.content.Context;
import android.util.Log;
@@ -16,7 +16,7 @@ import java.io.OutputStream;
/**
* The small zstd tarballs that ship inside the apk (the Turnip driver, the PulseAudio modules).
* The runtime rootfs is not one of these — it needs hard links and symlinks preserved, which
* {@link com.the412banner.steamdeck.runtime.LinuxRuntimeInstaller} handles itself.
* {@link com.steamdeck.launcher.runtime.LinuxRuntimeInstaller} handles itself.
*/
public final class TarZst {
private static final String TAG = "TarZst";
@@ -1,11 +1,11 @@
package com.the412banner.steamdeck.gpu;
package com.steamdeck.launcher.gpu;
import android.content.Context;
import android.os.Environment;
import android.util.Log;
import com.the412banner.steamdeck.core.FileUtils;
import com.the412banner.steamdeck.core.TarZst;
import com.steamdeck.launcher.core.FileUtils;
import com.steamdeck.launcher.core.TarZst;
import org.json.JSONObject;
@@ -1,4 +1,4 @@
package com.the412banner.steamdeck.input;
package com.steamdeck.launcher.input;
import android.view.KeyEvent;
@@ -1,4 +1,4 @@
package com.the412banner.steamdeck.input;
package com.steamdeck.launcher.input;
import android.util.Log;
@@ -1,4 +1,4 @@
package com.the412banner.steamdeck.input;
package com.steamdeck.launcher.input;
public class GamepadState {
/** Bit index of the Steam/Guide button; 0-9 are the face/shoulder/stick buttons. */
@@ -1,4 +1,4 @@
package com.the412banner.steamdeck.input;
package com.steamdeck.launcher.input;
import android.util.Log;
import android.view.InputDevice;
@@ -1,4 +1,4 @@
package com.the412banner.steamdeck.runtime
package com.steamdeck.launcher.runtime
import android.content.Context
import android.net.ConnectivityManager
@@ -7,7 +7,7 @@ import android.net.Network
import android.os.Build
import android.provider.Settings
import android.util.Log
import com.the412banner.steamdeck.core.EnvironmentComponent
import com.steamdeck.launcher.core.EnvironmentComponent
import java.io.File
import java.io.IOException
import java.net.Inet4Address
@@ -1,4 +1,4 @@
package com.the412banner.steamdeck.runtime;
package com.steamdeck.launcher.runtime;
import android.content.Context;
import android.os.Process;
@@ -1,11 +1,11 @@
package com.the412banner.steamdeck.runtime;
package com.steamdeck.launcher.runtime;
import android.content.Context;
import android.util.Log;
import com.the412banner.steamdeck.core.Downloader;
import com.the412banner.steamdeck.core.FileUtils;
import com.steamdeck.launcher.core.Downloader;
import com.steamdeck.launcher.core.FileUtils;
import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream;
@@ -0,0 +1,89 @@
package com.steamdeck.launcher.session
import android.content.Context
import android.os.Environment
import android.util.Log
import com.steamdeck.launcher.core.FileUtils
import com.steamdeck.launcher.runtime.LinuxRuntime
import java.io.File
/** Everything the session needs written into the runtime before it starts. */
object SessionFiles {
private const val TAG = "SessionFiles"
private const val NO_PAD_SWITCH = "Download/steamdeck-no-pad"
/**
* The libraries and scripts the session runs, refreshed from the apk at every launch.
*
* The runtime image carries its own copies, but a runtime installed months ago carries the
* copies of that day and a device has no way to replace them from outside the app. Staging
* them here is how a fix inside the session shim, the controller reader or one of the scripts
* reaches an already-installed runtime without a ~790 MB re-download. Each lands through a
* rename, so a session that still has one mapped keeps the file it opened.
*/
fun stage(context: Context, root: File) {
val files = arrayOf(
"libblsession.so" to "usr/local/lib/libblsession.so",
"libfakeinput.so" to "usr/local/lib/libfakeinput.so",
"usr/local/bin/bannerlator-session" to "usr/local/bin/bannerlator-session",
"usr/local/bin/bannerlator-steam-compat" to "usr/local/bin/bannerlator-steam-compat",
"usr/local/bin/bannerlator-steam-install" to "usr/local/bin/bannerlator-steam-install",
"usr/local/bin/bannerlator-steam-library" to "usr/local/bin/bannerlator-steam-library",
"usr/local/bin/bannerlator-seed-redists" to "usr/local/bin/bannerlator-seed-redists",
)
for ((asset, relative) in files) {
val target = File(root, relative)
val staged = File(target.parentFile, target.name + ".staged")
var installed = false
try {
target.parentFile?.mkdirs()
context.assets.open("linuxfs/$asset").use { input ->
staged.outputStream().use { output -> FileUtils.copy(input, output) }
}
installed = staged.setExecutable(true, false) && staged.renameTo(target)
} catch (e: Exception) {
Log.w(TAG, "could not stage $relative", e)
} finally {
if (!installed) staged.delete()
}
if (!installed) Log.e(TAG, "$relative NOT staged")
}
// What every process in the session preloads. LD_PRELOAD in the environment would not
// survive: the Steam client rebuilds it for each process it starts and appends its own
// overlay entry without a separator, which silently drops whatever was there.
val preload = StringBuilder("/usr/local/lib/libblsession.so\n")
if (!File(Environment.getExternalStorageDirectory(), NO_PAD_SWITCH).exists()) {
preload.append("/usr/local/lib/libfakeinput.so\n")
}
val etc = File(root, "etc").apply { mkdirs() }
val staged = File(etc, "ld.so.preload.staged")
if (!FileUtils.writeString(staged, preload.toString())
|| !staged.renameTo(File(etc, "ld.so.preload"))) {
staged.delete()
Log.e(TAG, "could not write ld.so.preload")
}
}
/**
* Where the session writes its log. Downloads is the point — a failed run is handed over as a
* folder rather than dug out of app-private storage — but the session script redirects its own
* output there with `exec`, and a redirection a non-interactive shell cannot open ends that
* shell. So a public directory is used only once it is proven writable; otherwise the app's
* own files directory, which is bound into the session anyway, stands in.
*/
fun logDirectory(context: Context): File {
val public = LinuxRuntime.debugLogDir()
if (public.isDirectory || public.mkdirs()) {
val probe = File(public, ".writable")
try {
if (probe.createNewFile() || probe.isFile) {
probe.delete()
return public
}
} catch (ignored: Exception) {
}
}
Log.w(TAG, "$public is not writable (storage permission?); logging to files/logs")
return File(context.filesDir, "logs").apply { mkdirs() }
}
}
@@ -0,0 +1,316 @@
package com.steamdeck.launcher.session
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.net.wifi.WifiManager
import android.os.Build
import android.os.Environment
import android.os.IBinder
import android.os.PowerManager
import android.util.Log
import com.steamdeck.launcher.R
import com.steamdeck.launcher.SessionActivity
import com.steamdeck.launcher.audio.PulseAudioComponent
import com.steamdeck.launcher.core.EnvVars
import com.steamdeck.launcher.core.EnvironmentComponent
import com.steamdeck.launcher.core.FileUtils
import com.steamdeck.launcher.core.ProcessHelper
import com.steamdeck.launcher.input.FakeInputWriter
import com.steamdeck.launcher.runtime.LinuxNetworkLinkComponent
import com.steamdeck.launcher.runtime.LinuxRuntime
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Owns the running session: the proot tree, the audio daemon, the network link and the locks that
* keep all three alive while the app is not on screen.
*
* The session deliberately does **not** belong to the activity. Android demotes a process the
* moment it loses its last visible activity, and the low-memory killer then reaps the guest — so
* leaving Big Picture to answer a message would come back to a dead Steam. A foreground service
* holds the process at perceptible priority, a partial wake lock keeps the CPU from dropping the
* guest's threads, and a high-performance WiFi lock keeps the radio out of power-save so a
* backgrounded download does not throttle to nothing. All three are Bannerlator's recipe, where
* each was added to fix a failure seen on a device.
*
* The activity comes and goes on top of this; see [com.steamdeck.launcher.wayland.CompositorHost].
*/
class SessionService : Service() {
private val components = ArrayList<EnvironmentComponent>()
private var wakeLock: PowerManager.WakeLock? = null
private var wifiLock: WifiManager.WifiLock? = null
private var sessionPid = -1
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_STOP) {
Log.i(TAG, "stop requested from the notification")
stopSession(0)
return START_NOT_STICKY
}
startForeground(NOTIFICATION_ID, buildNotification())
if (SessionState.running) return START_NOT_STICKY
SessionState.running = true
acquireLocks()
Thread({ runSession() }, "session-start").start()
// The activity or the notification stops us; the system must not resurrect a session whose
// guest processes are long gone.
return START_NOT_STICKY
}
// ── The session ─────────────────────────────────────────────────────────────────────────
private fun runSession() {
try {
LinuxRuntime.writeAccounts(this)
} catch (e: Exception) {
Log.e(TAG, "could not write the guest's passwd/group", e)
stopSession(-1)
return
}
val root = LinuxRuntime.rootDir(this)
val sessionRoot = LinuxRuntime.sessionRoot(this).apply { mkdirs() }
val runtimeDir = File(filesDir, ".wayland-rt").apply { mkdirs() }
SessionFiles.stage(this, root)
val logDir = SessionFiles.logDirectory(this)
val stamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date())
val sessionLog = File(logDir, "session-$stamp.log")
SessionState.logFile = sessionLog
val size = SessionState.outputSize
val guest = ArrayList<String>()
guest.add("/usr/bin/env")
guest.add("-i")
guest.add("HOME=/root")
guest.add("USER=root")
guest.add("PATH=/usr/local/bin:/usr/bin:/bin")
guest.add("TERM=xterm-256color")
guest.add("LANG=C.UTF-8")
guest.add("XDG_RUNTIME_DIR=" + runtimeDir.path)
guest.add("XDG_SESSION_TYPE=wayland")
guest.add("WAYLAND_DISPLAY=wayland-0")
guest.add("GAMESCOPE_FORCE_GENERAL_QUEUE=1")
// Steam's CEF needs GL and the rootfs ships no native GL driver: route it through Zink.
guest.add("MESA_LOADER_DRIVER_OVERRIDE=zink")
guest.add("GALLIUM_DRIVER=zink")
guest.add("LIBGL_KOPPER_DRI2=true")
LinuxRuntime.vulkanIcd(this)?.let { guest.add("VK_ICD_FILENAMES=" + it.path) }
val pulse = PulseAudioComponent(this)
pulse.setContext(this)
guest.add("PULSE_SERVER=unix:" + pulse.socket().absolutePath)
components.add(pulse)
guest.add("BL_WIDTH=" + size.first)
guest.add("BL_HEIGHT=" + size.second)
guest.add("BL_FPS=0")
guest.add("BL_REFRESH=" + Math.round(SessionState.refreshHz))
guest.add("BL_LOG=" + sessionLog.path)
guest.add("BL_DEBUG_DIR=" + File(logDir, "session-$stamp").path)
val fakeInputDir = File(sessionRoot, "dev/input").apply { mkdirs() }
val controllersOn = !File(Environment.getExternalStorageDirectory(), NO_PAD_SWITCH).exists()
if (controllersOn) {
FakeInputWriter.prepareRingSlots(fakeInputDir, 4)
guest.add("FAKE_EVDEV_DIR=" + fakeInputDir.path)
val rings = FakeInputWriter.getRingEnv(fakeInputDir)
if (!rings.isNullOrEmpty()) guest.add("FAKE_EVDEV_MEMFD_PATHS=$rings")
// SDL and Steam key their mapping database on bus+vendor+product: only a known
// identity gets the standard layout without the user configuring the pad by hand.
guest.add("FAKE_EVDEV_IDENTITY=xbox360")
guest.add("FAKE_EVDEV_VIBRATION=1")
guest.add("FAKE_EVDEV_STEAM_VIRTUAL=1")
guest.add("SDL_JOYSTICK_DISABLE_UDEV=1")
guest.add("SDL_HIDAPI_JOYSTICK_DISABLE_UDEV=1")
guest.add("SDL_JOYSTICK_HIDAPI=0")
if (File(Environment.getExternalStorageDirectory(), PAD_LOG_SWITCH).exists()) {
guest.add("FAKE_EVDEV_LOG=1")
}
SessionState.fakeInputDir = fakeInputDir
}
guest.add(LinuxRuntime.SESSION_SCRIPT)
guest.add(LinuxRuntime.MODE_STEAM)
// Android has no /dev/shm; the cache stands in for it and, unlike the real thing, keeps
// whatever a session leaves behind. The client abandons tens of megabytes of streams a run.
FileUtils.clear(File(cacheDir, "shm"))
val binds = ArrayList<String>()
if (controllersOn) binds.add(fakeInputDir.path + ":/dev/input")
val command = LinuxRuntime.command(
this, sessionRoot, runtimeDir, Environment.getExternalStorageDirectory(), binds, guest,
)
val hostEnv = EnvVars()
hostEnv.put("PROOT_LOADER", LinuxRuntime.prootLoader(this).path)
hostEnv.put("PROOT_TMP_DIR", cacheDir.path)
// proot links against a libtalloc beside it, and Android's linker does not search an
// executable's own directory: unnamed, the process dies before it starts and says so only
// in `logcat -b crash`.
val prootLibs = LinuxRuntime.prootLibraryPath(this)
if (prootLibs.isNotEmpty()) hostEnv.put("LD_LIBRARY_PATH", prootLibs)
val networkLink = LinuxNetworkLinkComponent(this, root)
networkLink.setContext(this)
networkLink.publish()
components.add(networkLink)
components.forEach { it.start() }
val line = command.joinToString(" ") { it.replace(" ", "\\ ") }
sessionPid = ProcessHelper.exec(line, hostEnv.toStringArray(), root, { status ->
Log.i(TAG, "session ended: $status")
stopSession(status ?: -1)
}, null)
Log.i(TAG, "session pid $sessionPid, log ${sessionLog.path}")
}
private fun stopSession(status: Int) {
if (!SessionState.running) return
SessionState.running = false
// proot runs with --kill-on-exit, so killing it takes the whole guest tree with it.
if (sessionPid != -1) {
android.os.Process.killProcess(sessionPid)
sessionPid = -1
}
components.reversed().forEach {
try {
it.stop()
} catch (e: Exception) {
Log.w(TAG, "stopping ${it.javaClass.simpleName}", e)
}
}
components.clear()
FakeInputWriter.releaseAllRingSlots()
releaseLocks()
SessionState.notifyEnded(status)
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
/**
* Swiped out of recents. The activity is destroyed without any of our teardown running, so the
* guest would survive as an orphan holding the rootfs and the GPU. Treat the swipe as "quit".
*/
override fun onTaskRemoved(rootIntent: Intent?) {
Log.i(TAG, "task removed — ending the session")
stopSession(0)
super.onTaskRemoved(rootIntent)
}
override fun onDestroy() {
stopSession(0)
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? = null
// ── Keeping the process alive ───────────────────────────────────────────────────────────
private fun acquireLocks() {
try {
val power = getSystemService(Context.POWER_SERVICE) as? PowerManager
wakeLock = power?.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "SteamDeck:session")?.apply {
setReferenceCounted(false)
// Capped, so a crash on some path cannot pin the CPU awake for good. A session
// longer than this re-acquires from the notification tap; nothing else needs it.
acquire(12L * 60L * 60L * 1000L)
}
Log.i(TAG, "wake lock held=${wakeLock?.isHeld}")
} catch (t: Throwable) {
Log.w(TAG, "no wake lock (${t.message}) — the session may be killed in the background")
}
try {
val wifi = applicationContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager
@Suppress("DEPRECATION") // deprecated from API 29, still honoured; targetSdk is 28
wifiLock = wifi?.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "SteamDeck:session-wifi")
?.apply {
setReferenceCounted(false)
acquire()
}
Log.i(TAG, "wifi lock held=${wifiLock?.isHeld}")
} catch (t: Throwable) {
// A partial wake lock keeps the process alive but does not stop WiFi power-save from
// throttling a backgrounded download to nothing, which is what this lock is for.
Log.w(TAG, "no wifi lock (${t.message}) — a backgrounded download may stall")
}
}
private fun releaseLocks() {
try {
wakeLock?.takeIf { it.isHeld }?.release()
} catch (t: Throwable) {
Log.w(TAG, "releasing the wake lock", t)
}
wakeLock = null
try {
wifiLock?.takeIf { it.isHeld }?.release()
} catch (t: Throwable) {
Log.w(TAG, "releasing the wifi lock", t)
}
wifiLock = null
}
// ── Notification ────────────────────────────────────────────────────────────────────────
private fun buildNotification(): Notification {
val manager = getSystemService(NotificationManager::class.java)
// IMPORTANCE_LOW: it must never make a sound or push a heads-up over a game.
val channel = NotificationChannel(CHANNEL_ID, getString(R.string.session_channel),
NotificationManager.IMPORTANCE_LOW).apply {
description = getString(R.string.session_channel_description)
setShowBadge(false)
setSound(null, null)
enableVibration(false)
}
manager?.createNotificationChannel(channel)
val open = PendingIntent.getActivity(
this, 0,
Intent(this, SessionActivity::class.java)
.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT),
PendingIntent.FLAG_IMMUTABLE,
)
val stop = PendingIntent.getService(
this, 1, Intent(this, SessionService::class.java).setAction(ACTION_STOP),
PendingIntent.FLAG_IMMUTABLE,
)
return Notification.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_stat_session)
.setContentTitle(getString(R.string.app_name))
.setContentText(getString(R.string.session_notification))
.setContentIntent(open)
.addAction(Notification.Action.Builder(null, getString(R.string.stop_session), stop).build())
.setOngoing(true)
.setShowWhen(false)
.apply { if (Build.VERSION.SDK_INT >= 31) setForegroundServiceBehavior(Notification.FOREGROUND_SERVICE_IMMEDIATE) }
.build()
}
companion object {
private const val TAG = "SessionService"
private const val CHANNEL_ID = "session"
private const val NOTIFICATION_ID = 1001
const val ACTION_STOP = "com.steamdeck.launcher.STOP_SESSION"
private const val NO_PAD_SWITCH = "Download/steamdeck-no-pad"
private const val PAD_LOG_SWITCH = "Download/steamdeck-pad-log"
fun start(context: Context) {
val intent = Intent(context, SessionService::class.java)
if (Build.VERSION.SDK_INT >= 26) context.startForegroundService(intent)
else context.startService(intent)
}
fun stop(context: Context) {
context.startService(Intent(context, SessionService::class.java).setAction(ACTION_STOP))
}
}
}
@@ -0,0 +1,36 @@
package com.steamdeck.launcher.session
import java.io.File
/**
* The little that the activity and the service have to agree on. Process-wide rather than passed
* through intents, because both halves live in one process and the point of the split is that
* either can outlive the other.
*/
object SessionState {
@Volatile
var running = false
/** The size gamescope was told to render at; set by the activity before the service starts. */
@Volatile
var outputSize: Pair<Int, Int> = Pair(1920, 1080)
@Volatile
var refreshHz: Float = 60f
/** Where this session is writing its log, for the "it ended" message. */
@Volatile
var logFile: File? = null
/** The fake evdev directory, once the service has prepared the rings. */
@Volatile
var fakeInputDir: File? = null
/** Told when the session ends, so a visible activity can close itself. */
@Volatile
var endListener: ((Int) -> Unit)? = null
fun notifyEnded(status: Int) {
endListener?.invoke(status)
}
}
@@ -0,0 +1,74 @@
package com.steamdeck.launcher.wayland
import android.view.Choreographer
import android.view.Surface
/**
* The compositor is a process-wide thing, not an activity's: it is started once, keeps running
* while the session does, and merely swaps the Surface it presents into as the activity comes and
* goes. An activity that started it again on re-creation would be starting a second compositor on
* a socket the first one owns.
*/
object CompositorHost {
@Volatile
private var started = false
private var vsyncRunning = false
private val vsync = object : Choreographer.FrameCallback {
override fun doFrame(frameTimeNanos: Long) {
WaylandCompositor.nativeVsync(frameTimeNanos)
if (vsyncRunning) Choreographer.getInstance().postFrameCallback(this)
}
}
/** True when this call was the one that started the compositor. */
@Synchronized
fun startOrAttach(
surface: Surface,
xdgRuntimeDir: String,
driverPath: String?,
libraryName: String?,
nativeLibDir: String,
outputWidth: Int,
outputHeight: Int,
refreshHz: Float,
): Boolean {
if (started) {
WaylandCompositor.nativeSetSurface(surface)
resumeVsync()
return false
}
WaylandCompositor.nativeSetOutputSize(outputWidth, outputHeight)
WaylandCompositor.nativeSetOutputRefreshRate(refreshHz)
WaylandCompositor.nativeStartWithSurface(
surface, xdgRuntimeDir, driverPath, libraryName, nativeLibDir,
)
started = true
resumeVsync()
return true
}
/**
* The activity is going away. The compositor keeps running with nothing to present into —
* the guest carries on, and its next frames land on the Surface the next activity brings.
*/
@Synchronized
fun detach() {
if (!started) return
pauseVsync()
WaylandCompositor.nativeSetSurface(null)
}
/** Frames are only worth drawing while a Surface is attached. */
private fun resumeVsync() {
if (vsyncRunning) return
vsyncRunning = true
Choreographer.getInstance().postFrameCallback(vsync)
}
private fun pauseVsync() {
vsyncRunning = false
}
val isStarted: Boolean get() = started
}
@@ -1,4 +1,4 @@
package com.the412banner.steamdeck.wayland;
package com.steamdeck.launcher.wayland;
import android.view.Surface;
@@ -1,500 +0,0 @@
package com.the412banner.steamdeck
import android.app.Activity
import android.os.Build
import android.os.Bundle
import android.os.Environment
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.Choreographer
import android.view.KeyEvent
import android.view.MotionEvent
import android.view.SurfaceHolder
import android.view.SurfaceView
import android.view.View
import android.view.WindowManager
import android.widget.TextView
import android.widget.FrameLayout
import com.the412banner.steamdeck.audio.PulseAudioComponent
import com.the412banner.steamdeck.core.EnvVars
import com.the412banner.steamdeck.core.EnvironmentComponent
import com.the412banner.steamdeck.core.FileUtils
import com.the412banner.steamdeck.core.ProcessHelper
import com.the412banner.steamdeck.gpu.TurnipDriver
import com.the412banner.steamdeck.input.EvdevKeys
import com.the412banner.steamdeck.input.FakeInputWriter
import com.the412banner.steamdeck.input.PadBridge
import com.the412banner.steamdeck.runtime.LinuxNetworkLinkComponent
import com.the412banner.steamdeck.runtime.LinuxRuntime
import com.the412banner.steamdeck.wayland.WaylandCompositor
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* The session: our Wayland compositor on this activity's Surface, and gamescope plus Valve's
* native arm64 Steam client running under proot inside the Linux runtime, presenting into it.
*
* Nothing here is Wine. proot and the session script are the whole of the guest side, and the
* activity's own job is three things: keep a Surface under the compositor, feed it input, and
* hold the session's host-side components (audio, the network link, the proot process) for as
* long as the client is up.
*
* Ported from Bannerlator's XServerDisplayActivity.setupLinuxSession, which in turn follows
* WinNative's gamescope runtime (GPL-3.0).
*/
class SessionActivity : Activity(), SurfaceHolder.Callback {
private lateinit var surfaceView: SurfaceView
private lateinit var statusView: TextView
private val components = ArrayList<EnvironmentComponent>()
private var padBridge: PadBridge? = null
private var compositorStarted = false
private var sessionStarted = false
private var finishing = false
private var sessionPid = -1
private var vsyncRunning = false
private lateinit var sessionLog: File
private val vsyncCallback = object : Choreographer.FrameCallback {
override fun doFrame(frameTimeNanos: Long) {
WaylandCompositor.nativeVsync(frameTimeNanos)
if (vsyncRunning) Choreographer.getInstance().postFrameCallback(this)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
goFullscreen()
if (!LinuxRuntime.isInstalled(this)) {
Log.e(TAG, "the Linux runtime is not installed")
finish()
return
}
val root = FrameLayout(this)
surfaceView = SurfaceView(this)
surfaceView.holder.addCallback(this)
root.addView(surfaceView)
// Until the client's first frame arrives there is nothing on screen for a minute or more
// on a first run, so the session's own milestones are shown instead of a black panel.
statusView = TextView(this).apply {
setPadding(48, 48, 48, 48)
setTextColor(0xFFDDDDDD.toInt())
textSize = 14f
text = getString(R.string.session_starting)
}
root.addView(statusView)
setContentView(root)
WaylandCompositor.setFirstFrameListener {
runOnUiThread { statusView.visibility = View.GONE }
}
}
// ── Compositor ──────────────────────────────────────────────────────────────────────────
override fun surfaceCreated(holder: SurfaceHolder) {
if (compositorStarted) {
WaylandCompositor.nativeSetSurface(holder.surface)
return
}
compositorStarted = true
val runtimeDir = File(filesDir, ".wayland-rt").apply { mkdirs() }
// The compositor hands this keymap to wl_keyboard clients, which is how the guest reads
// the evdev codes we inject.
FileUtils.copyAsset(this, "wayland/keymap.xkb", File(runtimeDir, "keymap.xkb"))
// Turnip, not the system Adreno driver: importing the dma-bufs gamescope commits needs
// VK_EXT_image_drm_format_modifier, which the system driver does not implement.
val turnip = TurnipDriver(this)
val driverId = turnip.install()
val driverPath = driverId?.let { turnip.driverPath(it) }
val libraryName = driverId?.let { turnip.libraryName(it) }
val size = outputSize()
WaylandCompositor.nativeSetOutputSize(size.first, size.second)
WaylandCompositor.nativeSetOutputRefreshRate(refreshHz())
WaylandCompositor.nativeStartWithSurface(
holder.surface, runtimeDir.path, driverPath, libraryName,
applicationInfo.nativeLibraryDir,
)
vsyncRunning = true
Choreographer.getInstance().postFrameCallback(vsyncCallback)
Thread({ startSession(runtimeDir, size) }, "session-start").start()
}
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {}
override fun surfaceDestroyed(holder: SurfaceHolder) {
WaylandCompositor.nativeSetSurface(null)
}
/**
* The size the session renders at. gamescope is told this and scales its output onto whatever
* the panel is, so a 1440p phone can run the client at 1080p without the client knowing.
*/
private fun outputSize(): Pair<Int, Int> {
// The panel, not the window: resources.displayMetrics is what is left after the system
// bars and the cutout are taken out, which sized the first session 1920x968 on a 1080p
// device and had gamescope patch its EDID to match.
val bounds = if (Build.VERSION.SDK_INT >= 30) {
windowManager.maximumWindowMetrics.bounds
} else {
val metrics = android.util.DisplayMetrics()
@Suppress("DEPRECATION")
windowManager.defaultDisplay.getRealMetrics(metrics)
android.graphics.Rect(0, 0, metrics.widthPixels, metrics.heightPixels)
}
var width = maxOf(bounds.width(), bounds.height())
var height = minOf(bounds.width(), bounds.height())
// The client's CEF is the heaviest thing in the session; above 1080p it costs frames for
// nothing anyone can see on a phone panel.
if (height > 1080) {
width = width * 1080 / height
height = 1080
}
// Odd sizes upset the scaler; both dimensions even is what every mode here would be.
return Pair(width and 1.inv(), height and 1.inv())
}
private fun refreshHz(): Float {
val display = if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay
val hz = display?.refreshRate ?: 60f
return if (hz > 1f) hz else 60f
}
// ── Session ─────────────────────────────────────────────────────────────────────────────
private fun startSession(runtimeDir: File, size: Pair<Int, Int>) {
try {
LinuxRuntime.writeAccounts(this)
} catch (e: Exception) {
Log.e(TAG, "could not write the guest's passwd/group", e)
endSession(-1)
return
}
val root = LinuxRuntime.rootDir(this)
val sessionRoot = LinuxRuntime.sessionRoot(this).apply { mkdirs() }
stageSessionFiles(root)
val logDir = logDirectory()
val stamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date())
sessionLog = File(logDir, "session-$stamp.log")
val guest = ArrayList<String>()
guest.add("/usr/bin/env")
guest.add("-i")
guest.add("HOME=/root")
guest.add("USER=root")
guest.add("PATH=/usr/local/bin:/usr/bin:/bin")
guest.add("TERM=xterm-256color")
guest.add("LANG=C.UTF-8")
guest.add("XDG_RUNTIME_DIR=" + runtimeDir.path)
guest.add("XDG_SESSION_TYPE=wayland")
guest.add("WAYLAND_DISPLAY=wayland-0")
guest.add("GAMESCOPE_FORCE_GENERAL_QUEUE=1")
// Steam's CEF needs GL and the rootfs ships no native GL driver: route it through Zink.
guest.add("MESA_LOADER_DRIVER_OVERRIDE=zink")
guest.add("GALLIUM_DRIVER=zink")
guest.add("LIBGL_KOPPER_DRI2=true")
LinuxRuntime.vulkanIcd(this)?.let { guest.add("VK_ICD_FILENAMES=" + it.path) }
val pulse = PulseAudioComponent(this)
pulse.setContext(this)
guest.add("PULSE_SERVER=unix:" + pulse.socket().absolutePath)
components.add(pulse)
guest.add("BL_WIDTH=" + size.first)
guest.add("BL_HEIGHT=" + size.second)
guest.add("BL_FPS=0")
// What gamescope advertises as the refresh rate, and what a game reads as the display's.
// Left unset it says 60, and a 120 Hz panel then offers only 60 Hz in game settings.
guest.add("BL_REFRESH=" + Math.round(refreshHz()))
guest.add("BL_LOG=" + sessionLog.path)
guest.add("BL_DEBUG_DIR=" + File(logDir, "session-$stamp").path)
// Controllers. WinHandler is a Wine thing and there is none here, so the pad is published
// into the rings by PadBridge instead; the reader is the same interposer either way.
val fakeInputDir = File(sessionRoot, "dev/input").apply { mkdirs() }
val controllersOn = !File(Environment.getExternalStorageDirectory(), NO_PAD_SWITCH).exists()
if (controllersOn) {
FakeInputWriter.prepareRingSlots(fakeInputDir, 4)
guest.add("FAKE_EVDEV_DIR=" + fakeInputDir.path)
val rings = FakeInputWriter.getRingEnv(fakeInputDir)
if (!rings.isNullOrEmpty()) guest.add("FAKE_EVDEV_MEMFD_PATHS=$rings")
// SDL and Steam key their mapping database on bus+vendor+product: only a known
// identity gets the standard layout without the user configuring the pad by hand.
guest.add("FAKE_EVDEV_IDENTITY=xbox360")
guest.add("FAKE_EVDEV_VIBRATION=1")
guest.add("FAKE_EVDEV_STEAM_VIRTUAL=1")
// No udev runs in the runtime: SDL and hidapi scan /dev/input themselves, and the
// netlink monitor they open anyway is answered by the session shim's stand-in.
guest.add("SDL_JOYSTICK_DISABLE_UDEV=1")
guest.add("SDL_HIDAPI_JOYSTICK_DISABLE_UDEV=1")
guest.add("SDL_JOYSTICK_HIDAPI=0")
if (File(Environment.getExternalStorageDirectory(), PAD_LOG_SWITCH).exists()) {
guest.add("FAKE_EVDEV_LOG=1")
}
padBridge = PadBridge(fakeInputDir).also { it.start() }
}
guest.add(LinuxRuntime.SESSION_SCRIPT)
guest.add(LinuxRuntime.MODE_STEAM)
// Android has no /dev/shm; the cache stands in for it and, unlike the real thing, keeps
// whatever a session leaves behind. The client abandons tens of megabytes of streams a run.
FileUtils.clear(File(cacheDir, "shm"))
val binds = ArrayList<String>()
// Apps may not list /dev/input, so the fake nodes are bound in as the whole directory.
if (controllersOn) binds.add(fakeInputDir.path + ":/dev/input")
val command = LinuxRuntime.command(
this, sessionRoot, runtimeDir, Environment.getExternalStorageDirectory(), binds, guest,
)
val hostEnv = EnvVars()
hostEnv.put("PROOT_LOADER", LinuxRuntime.prootLoader(this).path)
hostEnv.put("PROOT_TMP_DIR", cacheDir.path)
// proot links against a libtalloc beside it, and Android's linker does not search an
// executable's own directory: unnamed, the process dies before it starts and says so only
// in `logcat -b crash`.
val prootLibs = LinuxRuntime.prootLibraryPath(this)
if (prootLibs.isNotEmpty()) hostEnv.put("LD_LIBRARY_PATH", prootLibs)
val networkLink = LinuxNetworkLinkComponent(this, LinuxRuntime.rootDir(this))
networkLink.setContext(this)
networkLink.publish()
components.add(networkLink)
components.forEach { it.start() }
watchSessionLog()
val line = command.joinToString(" ") { it.replace(" ", "\\ ") }
sessionPid = ProcessHelper.exec(line, hostEnv.toStringArray(), root, { status ->
Log.i(TAG, "session ended: $status")
endSession(status)
}, null)
sessionStarted = true
Log.i(TAG, "session pid $sessionPid, log ${sessionLog.path}")
}
/**
* Where the session writes its log. Downloads is the point — a failed run is handed over as a
* folder rather than dug out of app-private storage — but the session script redirects its own
* output there with `exec`, and a redirection a non-interactive shell cannot open ends that
* shell. So a public directory is used only once it is proven writable; otherwise the app's
* own files directory, which is bound into the session anyway, stands in.
*/
private fun logDirectory(): File {
val public = LinuxRuntime.debugLogDir()
if (public.isDirectory || public.mkdirs()) {
val probe = File(public, ".writable")
try {
if (probe.createNewFile() || probe.isFile) {
probe.delete()
return public
}
} catch (ignored: Exception) {
}
}
Log.w(TAG, "$public is not writable (storage permission?); logging to files/logs")
return File(filesDir, "logs").apply { mkdirs() }
}
/**
* The libraries and scripts the session runs, refreshed from the apk at every launch.
*
* The runtime image carries its own copies, but a runtime installed months ago carries the
* copies of that day and a device has no way to replace them from outside the app. Staging
* them here is how a fix inside the session shim, the controller reader or one of the scripts
* reaches an already-installed runtime without a ~790 MB re-download. Each lands through a
* rename, so a session that still has one mapped keeps the file it opened.
*/
private fun stageSessionFiles(root: File) {
val files = arrayOf(
"libblsession.so" to "usr/local/lib/libblsession.so",
"libfakeinput.so" to "usr/local/lib/libfakeinput.so",
"usr/local/bin/bannerlator-session" to "usr/local/bin/bannerlator-session",
"usr/local/bin/bannerlator-steam-compat" to "usr/local/bin/bannerlator-steam-compat",
"usr/local/bin/bannerlator-steam-install" to "usr/local/bin/bannerlator-steam-install",
"usr/local/bin/bannerlator-steam-library" to "usr/local/bin/bannerlator-steam-library",
"usr/local/bin/bannerlator-seed-redists" to "usr/local/bin/bannerlator-seed-redists",
)
for ((asset, relative) in files) {
val target = File(root, relative)
val staged = File(target.parentFile, target.name + ".staged")
var installed = false
try {
target.parentFile?.mkdirs()
assets.open("linuxfs/$asset").use { input ->
staged.outputStream().use { output -> FileUtils.copy(input, output) }
}
installed = staged.setExecutable(true, false) && staged.renameTo(target)
} catch (e: Exception) {
Log.w(TAG, "could not stage $relative", e)
} finally {
if (!installed) staged.delete()
}
if (!installed) Log.e(TAG, "$relative NOT staged")
}
// What every process in the session preloads. LD_PRELOAD in the environment would not
// survive: the Steam client rebuilds it for each process it starts and appends its own
// overlay entry without a separator, which silently drops whatever was there.
val preload = StringBuilder("/usr/local/lib/libblsession.so\n")
if (!File(Environment.getExternalStorageDirectory(), NO_PAD_SWITCH).exists()) {
preload.append("/usr/local/lib/libfakeinput.so\n")
}
val etc = File(root, "etc").apply { mkdirs() }
val staged = File(etc, "ld.so.preload.staged")
if (!FileUtils.writeString(staged, preload.toString()) || !staged.renameTo(File(etc, "ld.so.preload"))) {
staged.delete()
Log.e(TAG, "could not write ld.so.preload")
}
}
/** Mirrors the session script's "== STEP" milestones onto the screen while it starts. */
private fun watchSessionLog() {
val handler = Handler(Looper.getMainLooper())
Thread({
var shown = ""
while (!finishing) {
try {
Thread.sleep(500)
val text = FileUtils.readString(sessionLog) ?: continue
val step = text.lineSequence().lastOrNull { it.startsWith("== STEP") } ?: continue
val message = step.substringAfter("== STEP ").substringAfter(' ')
if (message != shown) {
shown = message
handler.post { if (statusView.visibility == View.VISIBLE) statusView.text = message }
}
} catch (e: InterruptedException) {
return@Thread
} catch (ignored: Exception) {
}
}
}, "session-log").start()
}
private fun endSession(status: Int) {
if (finishing) return
finishing = true
runOnUiThread {
if (status != 0) {
statusView.visibility = View.VISIBLE
statusView.text = getString(R.string.session_ended, status, sessionLogName())
}
// A moment on screen, so a failure is readable rather than a flash of black.
Handler(Looper.getMainLooper()).postDelayed({ finish() }, if (status == 0) 0 else 4000)
}
}
private fun sessionLogName(): String =
if (::sessionLog.isInitialized) sessionLog.path else "-"
// ── Input ───────────────────────────────────────────────────────────────────────────────
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
if (padBridge?.onKeyEvent(event) == true) return true
// A hardware keyboard, forwarded to the compositor's wl_keyboard. This is how an account
// name and password get typed on the client's first run; the client's own on-screen
// keyboard covers a device without one, driven by touch or the pad.
if (compositorStarted && event.device != null && !PadBridge.isFromController(event.device)) {
val down = event.action == KeyEvent.ACTION_DOWN
if (down || event.action == KeyEvent.ACTION_UP) {
// An unmapped key may still be a real one on a foreign layout: its scan code is
// the evdev code the kernel gave Android in the first place.
var evdev = EvdevKeys.fromKeyCode(event.keyCode)
if (evdev < 0 && event.scanCode > 0) evdev = event.scanCode
if (evdev > 0) {
WaylandCompositor.nativeSendKey(evdev, if (down) 1 else 0)
return true
}
}
}
return super.dispatchKeyEvent(event)
}
override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean {
if (padBridge?.onMotionEvent(event) == true) return true
return super.dispatchGenericMotionEvent(event)
}
/**
* Touch as a mouse: where you touch is where the pointer goes, and a tap is a left click.
* The compositor's pointer space is a fixed 1920x1080 whatever the output size, so the view's
* coordinates are scaled into it.
*/
override fun onTouchEvent(event: MotionEvent): Boolean {
val width = surfaceView.width.takeIf { it > 0 } ?: return false
val height = surfaceView.height.takeIf { it > 0 } ?: return false
val action = when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> 0
MotionEvent.ACTION_MOVE -> 1
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> 2
else -> return false
}
val x = (event.x / width * 1920f).toInt().coerceIn(0, 1919)
val y = (event.y / height * 1080f).toInt().coerceIn(0, 1079)
WaylandCompositor.nativeSendPointer(action, x, y)
return true
}
// ── Lifecycle ───────────────────────────────────────────────────────────────────────────
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
if (hasFocus) goFullscreen()
}
private fun goFullscreen() {
@Suppress("DEPRECATION")
window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
or View.SYSTEM_UI_FLAG_FULLSCREEN
or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION)
if (Build.VERSION.SDK_INT >= 28) {
window.attributes = window.attributes.apply {
layoutInDisplayCutoutMode =
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
}
}
}
override fun onDestroy() {
finishing = true
vsyncRunning = false
padBridge?.stop()
// proot runs with --kill-on-exit, so killing it takes the whole guest tree with it.
if (sessionPid != -1) {
android.os.Process.killProcess(sessionPid)
sessionPid = -1
}
components.reversed().forEach {
try {
it.stop()
} catch (e: Exception) {
Log.w(TAG, "stopping ${it.javaClass.simpleName}", e)
}
}
components.clear()
FakeInputWriter.releaseAllRingSlots()
WaylandCompositor.setFirstFrameListener(null)
super.onDestroy()
}
companion object {
private const val TAG = "SessionActivity"
/** Turns the whole controller feature off, for a true baseline on a device we cannot reach. */
private const val NO_PAD_SWITCH = "Download/steamdeck-no-pad"
/** Traces every interposer call into the session log. */
private const val PAD_LOG_SWITCH = "Download/steamdeck-pad-log"
}
}
@@ -1,7 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp" android:height="108dp"
android:viewportWidth="108" android:viewportHeight="108">
<!-- A gamepad silhouette: the app is a handheld launcher and nothing else. -->
<path android:fillColor="#FFFFFFFF"
android:pathData="M34,42h40c8.8,0 16,7.2 16,16v4c0,5.5 -4.5,10 -10,10c-3.6,0 -6.9,-1.9 -8.7,-5l-2.6,-4.5h-29.4l-2.6,4.5c-1.8,3.1 -5.1,5 -8.7,5c-5.5,0 -10,-4.5 -10,-10v-4C18,49.2 25.2,42 34,42zM40,50v6h-6v4h6v6h4v-6h6v-4h-6v-6zM68,52a3,3 0 1,0 0.1,0zM76,60a3,3 0 1,0 0.1,0z" />
</vector>
@@ -0,0 +1,11 @@
<!--
Status-bar icon: Android renders it as a single-colour silhouette, so this is the launcher
controller reduced to one path, with the d-pad and the buttons punched out (evenOdd) rather
than drawn in a second colour that would never survive.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp"
android:viewportWidth="108" android:viewportHeight="108">
<path android:fillColor="#FFFFFFFF" android:fillType="evenOdd"
android:pathData="M40,36 H68 C79,36 87,44.5 87,55.5 v4 c0,6.6 -5.4,12 -12,12 c-4.5,0 -8.6,-2.5 -10.6,-6.5 L62,60 H46 l-2.4,5 C41.6,69 37.5,71.5 33,71.5 c-6.6,0 -12,-5.4 -12,-12 v-4 C21,44.5 29,36 40,36 Z M33,47.5 h4.5 v-4.5 h4.5 v4.5 h4.5 v4.5 h-4.5 v4.5 h-4.5 v-4.5 h-4.5 z M71,43 a2.5,2.5 0 1,0 0.01,0 z M77,47.5 a2.5,2.5 0 1,0 0.01,0 z M65,47.5 a2.5,2.5 0 1,0 0.01,0 z M71,52 a2.5,2.5 0 1,0 0.01,0 z M54,44 a6,6 0 1,0 0.01,0 z" />
</vector>
@@ -7,6 +7,14 @@
android:padding="32dp"
android:background="#FF101418">
<ImageView
android:id="@+id/logo"
android:layout_width="96dp"
android:layout_height="96dp"
android:layout_marginBottom="12dp"
android:contentDescription="@string/app_name"
android:src="@mipmap/ic_launcher" />
<TextView
android:id="@+id/title"
android:layout_width="wrap_content"
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<background android:drawable="@mipmap/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<background android:drawable="@mipmap/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FF1B2838</color>
</resources>
+4
View File
@@ -18,6 +18,10 @@
<string name="install_failed">Install failed — nothing was changed</string>
<string name="session_starting">Starting the session…</string>
<string name="session_ended">The session ended (%1$d)\n%2$s</string>
<string name="session_channel">Session</string>
<string name="session_channel_description">Keeps Steam running while the app is in the background</string>
<string name="session_notification">Steam is running — leave this notification alone</string>
<string name="stop_session">Stop session</string>
<string name="credits_line">by The412Banner and maxjivi05 · credits</string>
<string name="credits_title">Credits</string>
<string name="credits_body">SteamDeck\n\nThe412Banner — the app, the Wayland compositor, the runtime and the Steam session.\n\nmaxjivi05 (Max) — the gamescope runtime this is built on: the proot session, the session shim, the fake-evdev interposer and the controller work, from WinNative.\n\nGPL-3.0. Valve, Steam, Steam Deck and Proton are trademarks of Valve Corporation; this project is not affiliated with Valve. gamescope, Mesa, Turnip, Xwayland, PulseAudio, proot and Arch Linux ARM are their authors\' own.</string>
Binary file not shown.