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.
No files matched your search
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
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>
|
||||
@@ -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>
|
||||
|
||||