mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-14 19:27:09 -04:00
Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
633ef0814a | ||
|
|
7c422577b6 | ||
|
|
1cc1ee994d | ||
|
|
d8d375cb9a | ||
|
|
221644e8be | ||
|
|
448feb30bc | ||
|
|
240f1194c0 | ||
|
|
7b74eebce2 | ||
|
|
30741302fb | ||
|
|
645f7d0bf1 | ||
|
|
3c577fd9dd | ||
|
|
659ff4817f | ||
|
|
626adb62af | ||
|
|
71cb100a40 | ||
|
|
68002f3fa7 | ||
|
|
dae7592788 | ||
|
|
e10f5b4430 | ||
|
|
3e32b75a63 | ||
|
|
9530e7b394 |
No files matched your search
@@ -307,6 +307,9 @@ jobs:
|
||||
set -o pipefail
|
||||
./gradlew assembleRelease --console=plain -PndkVersion="$NDK_VERSION" 2>&1 | tee /tmp/build.log
|
||||
|
||||
- name: Steam import and game environment tests
|
||||
run: python3 -m unittest discover -s tools/tests -p 'test_*.py'
|
||||
|
||||
- name: Unit tests
|
||||
run: ./gradlew testReleaseUnitTest --console=plain -PndkVersion="$NDK_VERSION"
|
||||
|
||||
|
||||
@@ -29,3 +29,6 @@ app/src/main/jniLibs/arm64-v8a/libproot-loader.so
|
||||
droiddeck-release-key/
|
||||
password.txt
|
||||
__pycache__/
|
||||
|
||||
# Local signing with the release key (tools/build_local.sh); holds a password.
|
||||
.signing.env
|
||||
@@ -176,4 +176,5 @@ dependencies {
|
||||
implementation 'com.termux.termux-app:terminal-view:0.118.0'
|
||||
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
testImplementation 'org.robolectric:robolectric:4.16.1'
|
||||
}
|
||||
@@ -130,6 +130,9 @@
|
||||
<service
|
||||
android:name=".runtime.RuntimeInstallService"
|
||||
android:exported="false" />
|
||||
<service
|
||||
android:name=".core.WirelessAdbPairingService"
|
||||
android:exported="false" />
|
||||
<!-- Hands a zipped session folder to the share sheet; nothing else is exposed. -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
#include <time.h>
|
||||
#include <stdarg.h>
|
||||
#include <errno.h>
|
||||
#include <poll.h>
|
||||
#include <sys/timerfd.h>
|
||||
#include <sys/syscall.h>
|
||||
#include <sys/sysmacros.h>
|
||||
@@ -247,6 +248,10 @@ struct surface {
|
||||
int releases_pending; /* FPS limiter: buffers of this surface still to be released */
|
||||
|
||||
enum surface_role role;
|
||||
/* A role-less surface's last buffer, held unreleased: it may become the pointer's image
|
||||
* (wl_pointer.set_cursor after the commit, which is wlroots' order). */
|
||||
struct wl_resource *idle_buffer;
|
||||
struct wl_listener idle_buffer_destroy;
|
||||
struct wl_resource *xdg_surface, *xdg_toplevel;
|
||||
struct wl_resource *viewport;
|
||||
|
||||
@@ -475,6 +480,7 @@ static int g_ntouches;
|
||||
#define CURSOR_MAX_PX (256 * 256)
|
||||
static pthread_mutex_t g_cursor_lock = PTHREAD_MUTEX_INITIALIZER;
|
||||
static struct surface *g_cursor_surface; /* compositor thread only */
|
||||
static struct surface *g_cursor_shown; /* compositor thread: whose image g_cursor_px holds */
|
||||
static int g_cursor_hx, g_cursor_hy; /* hotspot, surface-local */
|
||||
static uint32_t g_cursor_px[CURSOR_MAX_PX]; /* guarded by g_cursor_lock: ARGB8888 snapshot */
|
||||
static uint32_t g_cursor_rb[CURSOR_MAX_PX]; /* compositor thread: GPU readback staging */
|
||||
@@ -482,6 +488,14 @@ static int g_cursor_w, g_cursor_h; /* 0 = nothing to draw */
|
||||
static int g_cursor_hidden = 1; /* the client asked for no pointer */
|
||||
static int g_cursor_serial; /* bumped on every change; the app polls it */
|
||||
|
||||
/* The last image again, after a hide: the same surface set as the pointer once more with nothing
|
||||
* newly committed to it (wlroots re-sets its cursor surface each time the pointer enters). */
|
||||
static void cursor_publish_shown(void) {
|
||||
pthread_mutex_lock(&g_cursor_lock);
|
||||
if (g_cursor_w > 0) { g_cursor_hidden = 0; g_cursor_serial++; } /* serial: the hotspot may be new */
|
||||
pthread_mutex_unlock(&g_cursor_lock);
|
||||
}
|
||||
|
||||
static void cursor_publish_hidden(void) {
|
||||
pthread_mutex_lock(&g_cursor_lock);
|
||||
if (!g_cursor_hidden) { g_cursor_hidden = 1; g_cursor_serial++; }
|
||||
@@ -931,6 +945,68 @@ static void surface_set_input(struct wl_client *c, struct wl_resource *r,
|
||||
|
||||
static void send_toplevel_configure(struct surface *s);
|
||||
|
||||
/* The pointer image from a cursor surface's buffer: wl_shm is copied, a dma-buf (labwc on a GPU
|
||||
* renderer) is read back once the client's render into it is done - its implicit fence, as the
|
||||
* zero-copy path waits for it (ahb_swapchain_present). */
|
||||
static void cursor_publish_buffer(struct surface *s, struct wl_resource *buffer) {
|
||||
struct dmabuf_buffer *db = get_dmabuf(buffer);
|
||||
struct wl_shm_buffer *shm = db ? NULL : wl_shm_buffer_get(buffer);
|
||||
if (shm) {
|
||||
cursor_publish_shm(shm, g_cursor_hx, g_cursor_hy);
|
||||
} else if (db && db->n_planes >= 1 && db->width > 0 && db->height > 0 &&
|
||||
(int64_t)db->width * db->height <= CURSOR_MAX_PX) {
|
||||
if (!db->img && !db->import_failed) {
|
||||
db->img = vkp_image_from_dmabuf(db->fd[0], db->format, db->modifier, db->width, db->height,
|
||||
db->stride[0], db->offset[0]);
|
||||
if (!db->img) db->import_failed = 1;
|
||||
}
|
||||
if (!db->img) return;
|
||||
struct pollfd p = {.fd = db->fd[0], .events = POLLIN};
|
||||
int r;
|
||||
do { r = poll(&p, 1, 100); } while (r < 0 && errno == EINTR);
|
||||
if (vkp_image_readback(db->img, g_cursor_rb, CURSOR_MAX_PX) != 0) return;
|
||||
cursor_publish_pixels((const uint8_t *)g_cursor_rb, db->width, db->height,
|
||||
(size_t)db->width * 4, g_cursor_hx, g_cursor_hy);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
g_cursor_shown = s;
|
||||
}
|
||||
|
||||
static void on_idle_buffer_destroyed(struct wl_listener *l, void *data) {
|
||||
struct surface *s = wl_container_of(l, s, idle_buffer_destroy);
|
||||
wl_list_remove(&s->idle_buffer_destroy.link);
|
||||
wl_list_init(&s->idle_buffer_destroy.link);
|
||||
s->idle_buffer = NULL;
|
||||
}
|
||||
|
||||
/* Only what could be a pointer image is held; anything larger goes straight back. */
|
||||
static void surface_hold_idle(struct surface *s, struct wl_resource *buffer) {
|
||||
struct dmabuf_buffer *db = get_dmabuf(buffer);
|
||||
struct wl_shm_buffer *shm = db ? NULL : wl_shm_buffer_get(buffer);
|
||||
int64_t px = db ? (int64_t)db->width * db->height
|
||||
: shm ? (int64_t)wl_shm_buffer_get_width(shm) * wl_shm_buffer_get_height(shm) : 0;
|
||||
if (px <= 0 || px > CURSOR_MAX_PX) { wl_buffer_send_release(buffer); return; }
|
||||
s->idle_buffer = buffer;
|
||||
s->idle_buffer_destroy.notify = on_idle_buffer_destroyed;
|
||||
wl_resource_add_destroy_listener(buffer, &s->idle_buffer_destroy);
|
||||
}
|
||||
|
||||
/* Lets go of the held buffer; release = give it back to the client (not while it is going away). */
|
||||
static struct wl_resource *surface_take_idle(struct surface *s) {
|
||||
struct wl_resource *buffer = s->idle_buffer;
|
||||
if (!buffer) return NULL;
|
||||
wl_list_remove(&s->idle_buffer_destroy.link);
|
||||
wl_list_init(&s->idle_buffer_destroy.link);
|
||||
s->idle_buffer = NULL;
|
||||
return buffer;
|
||||
}
|
||||
|
||||
static void surface_drop_idle(struct surface *s) {
|
||||
struct wl_resource *buffer = surface_take_idle(s);
|
||||
if (buffer) wl_buffer_send_release(buffer);
|
||||
}
|
||||
|
||||
static void surface_commit(struct wl_client *c, struct wl_resource *r) {
|
||||
struct surface *s = wl_resource_get_user_data(r);
|
||||
struct surface *child;
|
||||
@@ -970,28 +1046,19 @@ static void surface_commit(struct wl_client *c, struct wl_resource *r) {
|
||||
s->pending_buffer = NULL;
|
||||
s->pending_attach = 0;
|
||||
|
||||
surface_drop_idle(s); /* replaced, whatever the surface is now */
|
||||
if (s->role == ROLE_CURSOR) {
|
||||
/* The client's pointer image, copied out for the app's overlay (see cursor_publish_*)
|
||||
* rather than composited, so it survives the zero-copy and HDR layer paths. */
|
||||
if (!buffer) {
|
||||
cursor_publish_hidden(); /* wlroots clears its cursor surface to hide the pointer */
|
||||
} else if (shm) {
|
||||
cursor_publish_shm(shm, g_cursor_hx, g_cursor_hy);
|
||||
} else if (db && db->n_planes >= 1 && db->width > 0 && db->height > 0 &&
|
||||
(int64_t)db->width * db->height <= CURSOR_MAX_PX) {
|
||||
if (!db->img && !db->import_failed) {
|
||||
db->img = vkp_image_from_dmabuf(db->fd[0], db->format, db->modifier, db->width, db->height,
|
||||
db->stride[0], db->offset[0]);
|
||||
if (!db->img) db->import_failed = 1;
|
||||
}
|
||||
if (db->img && vkp_image_readback(db->img, g_cursor_rb, CURSOR_MAX_PX) == 0)
|
||||
cursor_publish_pixels((const uint8_t *)g_cursor_rb, db->width, db->height,
|
||||
(size_t)db->width * 4, g_cursor_hx, g_cursor_hy);
|
||||
} else {
|
||||
cursor_publish_buffer(s, buffer);
|
||||
wl_buffer_send_release(buffer);
|
||||
}
|
||||
if (buffer) wl_buffer_send_release(buffer);
|
||||
} else if (s->role == ROLE_NONE) {
|
||||
/* Role-less surface: never drawn. */
|
||||
if (buffer) wl_buffer_send_release(buffer);
|
||||
/* Role-less surface: never drawn, but its buffer is kept for a set_cursor to come. */
|
||||
if (buffer) surface_hold_idle(s, buffer);
|
||||
} else if (db) {
|
||||
take_dmabuf(s, db, buffer);
|
||||
} else if (shm) {
|
||||
@@ -1063,6 +1130,8 @@ static void surface_resource_destroy(struct wl_resource *r) {
|
||||
for (int i = 0; i < g_nkbs; i++) if (g_kbs[i].focus == r) g_kbs[i].focus = NULL;
|
||||
touch_cancel_surface(s);
|
||||
if (g_cursor_surface == s) { g_cursor_surface = NULL; cursor_publish_hidden(); }
|
||||
if (g_cursor_shown == s) g_cursor_shown = NULL;
|
||||
surface_take_idle(s);
|
||||
if (g_grab == s) g_grab = NULL;
|
||||
if (g_key_target == s) g_key_target = NULL;
|
||||
if (g_ime_click == s) g_ime_click = NULL;
|
||||
@@ -1139,6 +1208,7 @@ static void compositor_create_surface(struct wl_client *c, struct wl_resource *r
|
||||
wl_list_init(&s->child_link);
|
||||
wl_list_init(&s->toplevel_link);
|
||||
wl_list_init(&s->pending_buffer_destroy.link);
|
||||
wl_list_init(&s->idle_buffer_destroy.link);
|
||||
wl_list_insert(&g_surfaces, &s->link);
|
||||
wl_resource_set_implementation(s->resource, &surface_impl, s, surface_resource_destroy);
|
||||
}
|
||||
@@ -2143,6 +2213,16 @@ static void pointer_set_cursor(struct wl_client *c, struct wl_resource *r, uint3
|
||||
g_cursor_surface = s;
|
||||
g_cursor_hx = hx;
|
||||
g_cursor_hy = hy;
|
||||
/* Its content may already be committed: wlroots' Wayland backend attaches and commits the
|
||||
* cursor surface first and sets it as the pointer after, and sets it again with nothing new
|
||||
* committed each time the pointer re-enters. */
|
||||
struct wl_resource *buffer = surface_take_idle(s);
|
||||
if (buffer) {
|
||||
cursor_publish_buffer(s, buffer);
|
||||
wl_buffer_send_release(buffer);
|
||||
} else if (g_cursor_shown == s) {
|
||||
cursor_publish_shown();
|
||||
}
|
||||
}
|
||||
static void pointer_release(struct wl_client *c, struct wl_resource *r) { wl_resource_destroy(r); }
|
||||
static const struct wl_pointer_interface pointer_impl = {
|
||||
|
||||
@@ -45,6 +45,7 @@ import com.droiddeck.launcher.core.CpuCores
|
||||
import com.droiddeck.launcher.core.PhantomProcessLimit
|
||||
import com.droiddeck.launcher.core.PhantomProcessStatus
|
||||
import com.droiddeck.launcher.core.WirelessAdbFix
|
||||
import com.droiddeck.launcher.core.WirelessAdbPairingService
|
||||
import com.droiddeck.launcher.ui.CoreRow
|
||||
import com.droiddeck.launcher.ui.PerformancePage
|
||||
import com.droiddeck.launcher.ui.ModeSettingsPage
|
||||
@@ -124,6 +125,7 @@ class MainActivity : ComponentActivity() {
|
||||
private var zinkLazy by mutableStateOf(false)
|
||||
private var noXalia by mutableStateOf(false)
|
||||
private var prootNoSeccomp by mutableStateOf(false)
|
||||
private var guestHostname by mutableStateOf(SessionPrefs.DEFAULT_GUEST_HOSTNAME)
|
||||
private var phantomWarning by mutableStateOf<String?>(null)
|
||||
private var phantomProcessStatus by mutableStateOf(PhantomProcessStatus.NOT_APPLICABLE)
|
||||
private var showPhantomGate by mutableStateOf(false)
|
||||
@@ -430,6 +432,12 @@ class MainActivity : ComponentActivity() {
|
||||
android.widget.Toast.makeText(this, "ADB command copied", android.widget.Toast.LENGTH_SHORT).show()
|
||||
},
|
||||
onDismissPhantomGate = { showPhantomGate = false },
|
||||
onStartWirelessAdbPairing = { WirelessAdbPairingService.start(this) },
|
||||
onOpenNotificationSettings = {
|
||||
startActivity(Intent(android.provider.Settings.ACTION_APP_NOTIFICATION_SETTINGS)
|
||||
.putExtra(android.provider.Settings.EXTRA_APP_PACKAGE, packageName)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
},
|
||||
controller = ControllerActions(
|
||||
onOsc = { o -> SessionPrefs.setOscMode(this, o); oscMode = o },
|
||||
onTint = { t -> ControllerPrefs.setTint(this, t); refreshController() },
|
||||
@@ -864,7 +872,7 @@ class MainActivity : ComponentActivity() {
|
||||
cores = CpuCores.all.map { c -> CoreRow(c, "cpu$c" + (CpuCores.maxGhz(c)?.let { String.format(java.util.Locale.US, " · %.1f GHz", it) } ?: "")) },
|
||||
clientOverride = clientOverride, clientCores = clientCores, gameCores = gameCores,
|
||||
tuSysmem = tuSysmem, zinkLazy = zinkLazy, glThread = glThread, noGlError = noGlError, steamDeckMode = steamDeckMode, noXalia = noXalia,
|
||||
prootNoSeccomp = prootNoSeccomp, phantomWarning = phantomWarning,
|
||||
prootNoSeccomp = prootNoSeccomp, guestHostname = guestHostname, phantomWarning = phantomWarning,
|
||||
onClientOverride = { on -> SessionPrefs.setClientCpusOverride(this, on); clientOverride = on },
|
||||
onTuSysmem = { on -> SessionPrefs.setTuSysmem(this, on); tuSysmem = on },
|
||||
onZinkLazy = { on -> SessionPrefs.setZinkLazy(this, on); zinkLazy = on },
|
||||
@@ -873,6 +881,7 @@ class MainActivity : ComponentActivity() {
|
||||
onSteamDeckMode = { on -> SessionPrefs.setSteamDeckMode(this, on); steamDeckMode = on },
|
||||
onNoXalia = { on -> SessionPrefs.setNoXalia(this, on); noXalia = on },
|
||||
onProotNoSeccomp = { on -> SessionPrefs.setProotNoSeccomp(this, on); prootNoSeccomp = on },
|
||||
onGuestHostname = { name -> SessionPrefs.setGuestHostname(this, name) },
|
||||
onClientCore = { core, on ->
|
||||
clientCores = if (on) clientCores + core else clientCores - core
|
||||
SessionPrefs.setClientCpus(this, CpuCores.format(clientCores))
|
||||
@@ -965,6 +974,7 @@ class MainActivity : ComponentActivity() {
|
||||
zinkLazy = SessionPrefs.zinkLazy(this)
|
||||
noXalia = SessionPrefs.noXalia(this)
|
||||
prootNoSeccomp = SessionPrefs.prootNoSeccomp(this)
|
||||
guestHostname = SessionPrefs.guestHostname(this)
|
||||
refreshPhantomStatus()
|
||||
}
|
||||
|
||||
@@ -988,14 +998,14 @@ class MainActivity : ComponentActivity() {
|
||||
val games = if (ready) Library.steamGames(this) + com.droiddeck.launcher.frontend.AddedGames.scan(this).map { g ->
|
||||
com.droiddeck.launcher.frontend.AddedGameArt.resolve(this, g).let { art ->
|
||||
Library.SteamGame(
|
||||
g.appId.toInt(), g.name, art.portrait ?: art.header, "added", g.gameId,
|
||||
g.steamAppId ?: g.appId.toInt(), g.name, art.portrait ?: art.header, "added", g.gameId,
|
||||
hero = art.hero ?: art.header, gameFiles = g.folder,
|
||||
protonPrefix = Library.protonPrefix(this, g.appId),
|
||||
protonPrefix = Library.protonPrefix(this, g.steamAppId?.toLong() ?: g.appId),
|
||||
)
|
||||
}
|
||||
} else emptyList()
|
||||
val emus = Library.emulators(this) { id -> DesktopCatalog.installed(this, id) != null }
|
||||
ui.post { steamGames = games; emulatorList = emus }
|
||||
ui.post { steamGames = games.distinctBy { it.gameId }; emulatorList = emus }
|
||||
// Box art for the games that have none, fetched after the list is up; the list is
|
||||
// rebuilt once if any was found.
|
||||
if (!OfflineMode.enabled(this) && CoverArt.fetchMissing(this, emus.flatMap { it.games })) {
|
||||
@@ -1037,15 +1047,18 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
private fun refreshPhantomStatus() {
|
||||
phantomProcessStatus = PhantomProcessLimit.read(contentResolver)
|
||||
phantomProcessStatus = PhantomProcessLimit.read(this)
|
||||
phantomWarning = if (PhantomProcessLimit.blocksSteam(phantomProcessStatus)) {
|
||||
"${PhantomProcessLimit.title(phantomProcessStatus)}. ${PhantomProcessLimit.instructions(phantomProcessStatus)}\n\n${PhantomProcessLimit.ADB_COMMAND}"
|
||||
"${PhantomProcessLimit.title(phantomProcessStatus)}. ${PhantomProcessLimit.instructions(phantomProcessStatus)}\n\n${PhantomProcessLimit.adbCommand()}"
|
||||
} else null
|
||||
}
|
||||
|
||||
private fun openDeveloperOptions(displayId: Int?) {
|
||||
val highlight = if (PhantomProcessLimit.hasDeveloperToggle() &&
|
||||
WirelessAdbPairingService.stage.value == WirelessAdbPairingService.Stage.Idle) null else "toggle_adb_wireless"
|
||||
val intent = Intent(android.provider.Settings.ACTION_APPLICATION_DEVELOPMENT_SETTINGS)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
.apply { if (highlight != null) putExtra(":settings:fragment_args_key", highlight) }
|
||||
try {
|
||||
if (displayId == null) startActivity(intent)
|
||||
else startActivity(intent, ActivityOptions.makeBasic().setLaunchDisplayId(displayId).toBundle())
|
||||
|
||||
@@ -147,6 +147,7 @@ object DeviceReport {
|
||||
k("FEX preset", SessionPrefs.fexPreset(context).ifEmpty { "FEX defaults" })
|
||||
k("Skip xalia", SessionPrefs.noXalia(context))
|
||||
k("proot without seccomp", SessionPrefs.prootNoSeccomp(context))
|
||||
k("Guest host name", SessionPrefs.guestHostname(context))
|
||||
k("DirectAudio for games", SessionPrefs.directAudio(context))
|
||||
k("Stretch games to fill", SessionPrefs.forceFullscreen(context))
|
||||
k("Client audio", if (SessionPrefs.clientDirectAudio(context)) "DirectAudio" else "classic")
|
||||
@@ -161,7 +162,7 @@ object DeviceReport {
|
||||
})
|
||||
|
||||
h("Android process limits")
|
||||
k("Phantom proc monitor", PhantomProcessLimit.reportValue(PhantomProcessLimit.read(context.contentResolver)))
|
||||
k("Phantom proc monitor", PhantomProcessLimit.reportValue(PhantomProcessLimit.read(context)))
|
||||
|
||||
h("Device switch files in Download")
|
||||
for (name in listOf("droiddeck-env", "droiddeck-tu-debug", "droiddeck-driver",
|
||||
|
||||
@@ -1,42 +1,33 @@
|
||||
package com.droiddeck.launcher.core
|
||||
|
||||
/**
|
||||
* FEXCore presets for the x86 games the Steam client launches, Bannerlator's table carried over
|
||||
* so a preset means the same thing in both apps. Each is a set of FEX_* variables put into the
|
||||
* session's environment; the games under FEX inherit them from the client. The default is FEX's
|
||||
* own defaults - no variables at all - which is what every session has run on so far.
|
||||
*
|
||||
* TSO = x86's total store ordering emulated on ARM: safest, slowest. MULTIBLOCK compiles more per
|
||||
* block. X87REDUCEDPRECISION trades x87 exactness for speed. SMCCHECKS=none is the risky one -
|
||||
* anything that writes its own code at runtime (JIT, .NET, DRM) can misbehave. DENUVO keeps the
|
||||
* checks at full and hides the hypervisor bit, which its anti-tamper looks for.
|
||||
*/
|
||||
import com.droiddeck.launcher.R
|
||||
|
||||
object FexPreset {
|
||||
class Preset(val id: String, val label: String, val detail: String, val env: List<String>)
|
||||
class Preset(val id: String, val label: Int, val detail: Int, val env: List<String>)
|
||||
|
||||
private fun tso(main: Int, vector: Int, memcpy: Int, halfBarrier: Int) = listOf(
|
||||
"FEX_TSOENABLED=$main", "FEX_VECTORTSOENABLED=$vector", "FEX_MEMCPYSETTSOENABLED=$memcpy", "FEX_HALFBARRIERTSOENABLED=$halfBarrier",
|
||||
)
|
||||
|
||||
val all: List<Preset> = listOf(
|
||||
Preset("", "FEX defaults", "Uses FEX defaults.", emptyList()),
|
||||
Preset("STABILITY", "Stability", "Safest, slowest settings.", tso(1, 1, 1, 1) + listOf("FEX_X87REDUCEDPRECISION=0", "FEX_MULTIBLOCK=0")),
|
||||
Preset("COMPATIBILITY", "Compatibility", "Full TSO with multiblock.", tso(1, 1, 1, 1) + listOf("FEX_X87REDUCEDPRECISION=0", "FEX_MULTIBLOCK=1")),
|
||||
Preset("INTERMEDIATE", "Intermediate", "Balanced TSO with reduced x87 precision.", tso(1, 0, 0, 1) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1")),
|
||||
Preset("PERFORMANCE", "Performance", "All TSO disabled; multiblock.", tso(0, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1")),
|
||||
Preset("PERFORMANCE_TSO", "Performance + TSO", "Performance settings with main TSO enabled.", tso(1, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1")),
|
||||
Preset("EXTREME", "Extreme", "Disables self-modifying-code checks. JIT games and DRM may break.",
|
||||
Preset("", R.string.fex_default_label, R.string.fex_default_detail, emptyList()),
|
||||
Preset("STABILITY", R.string.fex_stability_label, R.string.fex_stability_detail, tso(1, 1, 1, 1) + listOf("FEX_X87REDUCEDPRECISION=0", "FEX_MULTIBLOCK=0")),
|
||||
Preset("COMPATIBILITY", R.string.fex_compatibility_label, R.string.fex_compatibility_detail, tso(1, 1, 1, 1) + listOf("FEX_X87REDUCEDPRECISION=0", "FEX_MULTIBLOCK=1")),
|
||||
Preset("INTERMEDIATE", R.string.fex_intermediate_label, R.string.fex_intermediate_detail, tso(1, 0, 0, 1) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1")),
|
||||
Preset("PERFORMANCE", R.string.fex_performance_label, R.string.fex_performance_detail, tso(0, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1")),
|
||||
Preset("PERFORMANCE_TSO", R.string.fex_performance_tso_label, R.string.fex_performance_tso_detail, tso(1, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1")),
|
||||
Preset("EXTREME", R.string.fex_extreme_label, R.string.fex_extreme_detail,
|
||||
tso(0, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1", "FEX_SMCCHECKS=none", "FEX_DISABLEL2CACHE=1", "FEX_DYNAMICL1CACHE=1", "FEX_DYNAMICL1CACHEINCREASECOUNTHEURISTIC=250", "FEX_DYNAMICL1CACHEDECREASECOUNTHEURISTIC=50")),
|
||||
Preset("EXTREME_TSO", "Extreme + TSO", "Extreme settings with main TSO enabled.",
|
||||
Preset("EXTREME_TSO", R.string.fex_extreme_tso_label, R.string.fex_extreme_tso_detail,
|
||||
tso(1, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1", "FEX_SMCCHECKS=none", "FEX_DISABLEL2CACHE=1", "FEX_DYNAMICL1CACHE=1", "FEX_DYNAMICL1CACHEINCREASECOUNTHEURISTIC=250", "FEX_DYNAMICL1CACHEDECREASECOUNTHEURISTIC=50")),
|
||||
Preset("EXTREME_GN", "Extreme-gn", "Performance with TSC and metadata hints. Try before Extreme.",
|
||||
Preset("EXTREME_GN", R.string.fex_extreme_gn_label, R.string.fex_extreme_gn_detail,
|
||||
tso(0, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1", "FEX_SMALLTSCSCALE=1", "FEX_VOLATILEMETADATA=1")),
|
||||
Preset("DENUVO", "Denuvo", "Performance with full code checks and the hypervisor bit hidden.",
|
||||
Preset("DENUVO", R.string.fex_denuvo_label, R.string.fex_denuvo_detail,
|
||||
tso(0, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1", "FEX_SMCCHECKS=full", "FEX_HIDEHYPERVISORBIT=1")),
|
||||
)
|
||||
|
||||
fun byId(id: String): Preset = all.firstOrNull { it.id == id } ?: all[0]
|
||||
|
||||
/** The variables for the session's environment, in KEY=VALUE form; empty for the default. */
|
||||
/** Game-launch variables in KEY=VALUE form. */
|
||||
fun env(id: String): List<String> = byId(id).env
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.droiddeck.launcher.core
|
||||
|
||||
object GameEnvironment {
|
||||
data class Config(
|
||||
val shared: Map<String, String?> = emptyMap(),
|
||||
val games: Map<String, Map<String, String?>> = emptyMap(),
|
||||
) {
|
||||
fun entries(scope: String) = if (scope.isEmpty()) shared else games[scope].orEmpty()
|
||||
|
||||
fun withEntries(scope: String, entries: Map<String, String?>): Config =
|
||||
if (scope.isEmpty()) copy(shared = entries.toMap())
|
||||
else copy(games = if (entries.isEmpty()) games - scope else games + (scope to entries.toMap()))
|
||||
}
|
||||
|
||||
private val namePattern = Regex("[A-Za-z_][A-Za-z0-9_]*")
|
||||
private val unused = setOf(
|
||||
"WINEESYNC", "WINENTSYNC", "WINE_FAST_YIELD", "WINE_DO_NOT_CREATE_DXGI_DEVICE_MANAGER",
|
||||
"WINE_NEW_MEDIASOURCE", "WRAPPER_MAX_IMAGE_COUNT", "WRAPPER_DMAHEAP_CACHED",
|
||||
"ALSA_LATENCY_MS", "ALSA_VOLUME", "ALSA_BASS_BOOST", "ALSA_PERFORMANCE_MODE",
|
||||
"DXVK_ASYNC", "DXVK_GPLASYNCCACHE",
|
||||
)
|
||||
|
||||
fun validName(name: String) = namePattern.matches(name)
|
||||
fun supported(name: String) = name !in unused && !name.startsWith("BOX64_") && !name.startsWith("BOX86_")
|
||||
fun validValue(value: String) = '\u0000' !in value && value.length <= 8192
|
||||
fun validScope(scope: String) = scope.isEmpty() || (Regex("[1-9][0-9]*").matches(scope) && scope.toLongOrNull()?.let { it in 1..4294967295L } == true)
|
||||
|
||||
fun defaults(preset: String): Map<String, String?> = linkedMapOf<String, String?>(
|
||||
"MESA_SHADER_CACHE_DISABLE" to "false",
|
||||
"VKD3D_FEATURE_LEVEL" to "12_2",
|
||||
"VKD3D_SHADER_MODEL" to "6_9",
|
||||
).apply {
|
||||
FexPreset.env(preset).forEach { put(it.substringBefore('='), it.substringAfter('=')) }
|
||||
}
|
||||
|
||||
fun effective(config: Config, preset: String, scope: String): Map<String, String?> =
|
||||
defaults(preset) + config.shared + if (scope.isEmpty()) emptyMap() else config.games[scope].orEmpty()
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.droiddeck.launcher.core
|
||||
|
||||
import com.droiddeck.launcher.R
|
||||
|
||||
object GameEnvironmentOptions {
|
||||
enum class Type { TOGGLE, CHOICE, MULTIPLE, NUMBER, TEXT }
|
||||
data class Option(val name: String, val value: String, val detail: Int, val type: Type, val choices: List<String> = emptyList())
|
||||
|
||||
val all = listOf(
|
||||
Option("MESA_SHADER_CACHE_DISABLE", "false", R.string.game_env_cache, Type.TOGGLE, listOf("false", "true")),
|
||||
Option("VKD3D_FEATURE_LEVEL", "12_2", R.string.game_env_feature, Type.CHOICE, listOf("12_2", "12_1", "12_0", "11_1", "11_0")),
|
||||
Option("VKD3D_SHADER_MODEL", "6_9", R.string.game_env_shader, Type.CHOICE, listOf("6_9", "6_8", "6_7", "6_6", "6_5", "6_4", "6_3", "6_2", "6_1", "6_0", "5_1")),
|
||||
Option("VKD3D_CONFIG", "nodxr", R.string.game_env_vkd3d_config, Type.MULTIPLE, listOf("nodxr", "single_queue", "no_upload_hvv")),
|
||||
Option("MESA_SHADER_CACHE_MAX_SIZE", "1G", R.string.game_env_cache_size, Type.CHOICE, listOf("512M", "1G", "2G", "4G")),
|
||||
Option("PROTON_LOG", "1", R.string.game_env_proton_log, Type.TOGGLE, listOf("0", "1")),
|
||||
Option("PROTON_USE_WINED3D", "1", R.string.game_env_wined3d, Type.TOGGLE, listOf("0", "1")),
|
||||
Option("PROTON_USE_XALIA", "0", R.string.game_env_xalia, Type.TOGGLE, listOf("0", "1")),
|
||||
Option("DXVK_HUD", "fps", R.string.game_env_hud, Type.MULTIPLE, listOf("scale=0.5", "scale=0.7", "opacity=0.5", "opacity=0.7", "devinfo", "fps", "frametimes", "submissions", "drawcalls", "pipelines", "descriptors", "memory", "gpuload", "version", "api", "cs", "compiler", "samplers")),
|
||||
Option("DXVK_CONFIG", "dxvk.maxFrameRate = 60", R.string.game_env_dxvk_config, Type.TEXT),
|
||||
Option("VKD3D_FRAME_RATE", "60", R.string.game_env_vkd3d_limit, Type.NUMBER),
|
||||
Option("mesa_glthread", "true", R.string.game_env_glthread, Type.TOGGLE, listOf("false", "true")),
|
||||
Option("ZINK_DESCRIPTORS", "auto", 0, Type.CHOICE, listOf("auto", "lazy", "cached", "notemplates")),
|
||||
Option("ZINK_DEBUG", "nir", 0, Type.MULTIPLE, listOf("nir", "spirv", "tgsi", "validation", "sync", "compact", "noreorder")),
|
||||
Option("SteamDeck", "0", 0, Type.TOGGLE, listOf("0", "1")),
|
||||
Option("FD_DEV_FEATURES", "enable_tp_ubwc_flag_hint=1", 0, Type.MULTIPLE, listOf("enable_tp_ubwc_flag_hint=1", "storage_8bit=1")),
|
||||
Option("TU_DEBUG", "forcecb", 0, Type.MULTIPLE, listOf("forcecb", "nocb", "startup", "deck_emu", "nir", "nobin", "sysmem", "gmem", "forcebin", "layout", "noubwc", "nomultipos", "nolrz", "nolrzfc", "perf", "perfc", "flushall", "syncdraw", "push_consts_per_stage", "rast_order", "unaligned_store", "log_skip_gmem_ops", "dynamic", "bos", "3d_load", "fdm", "noconform", "rd")),
|
||||
Option("IR3_SHADER_DEBUG", "nouboopt", 0, Type.MULTIPLE, listOf("nouboopt", "nopreamble", "noearlypreamble")),
|
||||
Option("MESA_EXTENSION_MAX_YEAR", "", 0, Type.TEXT),
|
||||
Option("MESA_GL_VERSION_OVERRIDE", "", 0, Type.TEXT),
|
||||
Option("PULSE_LATENCY_MSEC", "", 0, Type.NUMBER),
|
||||
Option("WINE_LARGE_ADDRESS_AWARE", "0", 0, Type.TOGGLE, listOf("0", "1")),
|
||||
Option("WINEDLLOVERRIDES", "", 0, Type.TEXT),
|
||||
Option("GALLIUM_HUD", "simple", 0, Type.MULTIPLE, listOf("simple", "fps", "frametime")),
|
||||
)
|
||||
|
||||
fun find(name: String): Option? = all.firstOrNull { it.name == name }
|
||||
|
||||
fun toggle(value: String, choice: String): String {
|
||||
val selected = value.split(',').filter { it.isNotEmpty() }.toMutableSet()
|
||||
if (!selected.remove(choice)) selected.add(choice)
|
||||
return selected.joinToString(",")
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.droiddeck.launcher.core
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
|
||||
@@ -14,17 +14,53 @@ enum class PhantomProcessStatus {
|
||||
}
|
||||
|
||||
object PhantomProcessLimit {
|
||||
const val ADB_COMMAND = "adb shell settings put global settings_enable_monitor_phantom_procs false"
|
||||
const val SHELL_COMMAND = "settings put global settings_enable_monitor_phantom_procs false"
|
||||
private const val SETTING = "settings_enable_monitor_phantom_procs"
|
||||
|
||||
fun adbCommand(enabled: Boolean): String =
|
||||
"adb shell settings put global $SETTING ${if (enabled) "true" else "false"}"
|
||||
private const val MAX_PHANTOM = "2147483647"
|
||||
private const val PREFS = "phantom-process-limit"
|
||||
private const val PREF_ANDROID_12_OFF = "android12-off"
|
||||
|
||||
fun read(resolver: ContentResolver, sdk: Int = Build.VERSION.SDK_INT): PhantomProcessStatus {
|
||||
fun usesDeviceConfig(sdk: Int = Build.VERSION.SDK_INT): Boolean = sdk == Build.VERSION_CODES.S
|
||||
|
||||
fun shellCommands(enabled: Boolean, sdk: Int = Build.VERSION.SDK_INT): List<String> = when {
|
||||
usesDeviceConfig(sdk) && enabled -> listOf(
|
||||
"device_config delete activity_manager max_phantom_processes",
|
||||
"device_config set_sync_disabled_for_tests none",
|
||||
)
|
||||
usesDeviceConfig(sdk) -> listOf(
|
||||
"device_config set_sync_disabled_for_tests persistent",
|
||||
"device_config put activity_manager max_phantom_processes $MAX_PHANTOM",
|
||||
)
|
||||
else -> listOf("settings put global $SETTING ${if (enabled) "true" else "false"}")
|
||||
}
|
||||
|
||||
fun verifyCommand(sdk: Int = Build.VERSION.SDK_INT): String =
|
||||
if (usesDeviceConfig(sdk)) "device_config get activity_manager max_phantom_processes"
|
||||
else "settings get global $SETTING"
|
||||
|
||||
fun verified(output: String, enabled: Boolean, sdk: Int = Build.VERSION.SDK_INT): Boolean {
|
||||
val value = output.trim()
|
||||
return if (usesDeviceConfig(sdk)) (value == MAX_PHANTOM) != enabled
|
||||
else value == if (enabled) "true" else "false"
|
||||
}
|
||||
|
||||
fun adbCommand(enabled: Boolean, sdk: Int = Build.VERSION.SDK_INT): String =
|
||||
shellCommands(enabled, sdk).joinToString(" && ") { "adb shell $it" }
|
||||
|
||||
fun adbCommand(): String = adbCommand(false)
|
||||
|
||||
fun rememberAndroid12(context: Context, off: Boolean) {
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putBoolean(PREF_ANDROID_12_OFF, off).apply()
|
||||
}
|
||||
|
||||
fun read(context: Context, sdk: Int = Build.VERSION.SDK_INT): PhantomProcessStatus {
|
||||
if (sdk < Build.VERSION_CODES.S) return PhantomProcessStatus.NOT_APPLICABLE
|
||||
if (usesDeviceConfig(sdk)) {
|
||||
val off = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getBoolean(PREF_ANDROID_12_OFF, false)
|
||||
return if (off) PhantomProcessStatus.DISABLED else PhantomProcessStatus.UNREADABLE
|
||||
}
|
||||
val global = try {
|
||||
Settings.Global.getString(resolver, SETTING)
|
||||
Settings.Global.getString(context.contentResolver, SETTING)
|
||||
} catch (_: Exception) {
|
||||
return PhantomProcessStatus.UNREADABLE
|
||||
}
|
||||
@@ -57,6 +93,8 @@ object PhantomProcessLimit {
|
||||
null
|
||||
}
|
||||
|
||||
fun hasDeveloperToggle(sdk: Int = Build.VERSION.SDK_INT): Boolean = sdk >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE
|
||||
|
||||
fun blocksSteam(status: PhantomProcessStatus): Boolean =
|
||||
status != PhantomProcessStatus.NOT_APPLICABLE && status != PhantomProcessStatus.DISABLED
|
||||
|
||||
@@ -69,20 +107,20 @@ object PhantomProcessLimit {
|
||||
}
|
||||
|
||||
fun instructions(status: PhantomProcessStatus): String = when (status) {
|
||||
PhantomProcessStatus.ENABLED -> "Turn off “Restrict child processes” in Developer options. If you cannot find that option, connect this device to a computer with ADB and run the command below. Return here and check again."
|
||||
PhantomProcessStatus.UNSET -> "This setting is unset, so the ROM default applies and may enforce the limit. Set “Restrict child processes” to off in Developer options. If you cannot find that option, connect this device to a computer with ADB and run the command below. Return here and check again."
|
||||
PhantomProcessStatus.UNREADABLE -> "DroidDeck could not read the setting. Set “Restrict child processes” to off in Developer options. If you cannot find that option, connect this device to a computer with ADB and run the command below. Return here and check again."
|
||||
PhantomProcessStatus.ENABLED, PhantomProcessStatus.UNSET, PhantomProcessStatus.UNREADABLE -> fixSentence() + " If that is not possible, connect this device to a computer with ADB and run the command below. Return here and check again."
|
||||
PhantomProcessStatus.DISABLED -> "Steam sessions can start."
|
||||
PhantomProcessStatus.NOT_APPLICABLE -> "This Android version does not use this limit."
|
||||
}
|
||||
|
||||
fun gateInstructions(status: PhantomProcessStatus): String = when (status) {
|
||||
PhantomProcessStatus.ENABLED -> "Turn off “Restrict child processes” in Developer options."
|
||||
PhantomProcessStatus.UNSET -> "The ROM default may enforce this limit. Explicitly turn off “Restrict child processes” in Developer options."
|
||||
PhantomProcessStatus.UNREADABLE -> "DroidDeck could not check this setting. Turn off “Restrict child processes” in Developer options."
|
||||
PhantomProcessStatus.ENABLED, PhantomProcessStatus.UNSET, PhantomProcessStatus.UNREADABLE -> fixSentence()
|
||||
else -> instructions(status)
|
||||
}
|
||||
|
||||
fun fixSentence(sdk: Int = Build.VERSION.SDK_INT): String =
|
||||
if (hasDeveloperToggle(sdk)) "Turn on “Disable child process restrictions” in Developer options, and keep Developer options on afterwards."
|
||||
else "This Android version has no switch for it. Use Wireless debugging to change it from this device."
|
||||
|
||||
fun reportValue(status: PhantomProcessStatus): String = when (status) {
|
||||
PhantomProcessStatus.DISABLED -> "disabled (good - the OS will not kill the session's children)"
|
||||
PhantomProcessStatus.ENABLED -> "ENABLED (the OS may kill the session with no log; turn off Restrict child processes)"
|
||||
|
||||
@@ -6,7 +6,9 @@ import android.net.nsd.NsdServiceInfo
|
||||
import com.flyfishxu.kadb.Kadb
|
||||
import com.flyfishxu.kadb.cert.KadbCert
|
||||
import java.io.File
|
||||
import java.net.Inet4Address
|
||||
import java.net.InetAddress
|
||||
import java.net.NetworkInterface
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
@@ -19,6 +21,7 @@ object WirelessAdbFix {
|
||||
private const val KEY_FILE = "wireless-adb-key.pem"
|
||||
private const val HOST_FILE = "wireless-adb-host.txt"
|
||||
private const val CONNECTION_FILE = "wireless-adb-connection.txt"
|
||||
const val LOOPBACK = "127.0.0.1"
|
||||
|
||||
private data class SavedConnection(val host: String, val port: Int?)
|
||||
|
||||
@@ -76,9 +79,27 @@ object WirelessAdbFix {
|
||||
}
|
||||
|
||||
/** Finds this paired device's separate TLS connection port from Android's ADB mDNS record. */
|
||||
fun isLocalAddress(address: InetAddress): Boolean = runCatching {
|
||||
if (address.isLoopbackAddress) return true
|
||||
NetworkInterface.getNetworkInterfaces()?.toList().orEmpty().any { network ->
|
||||
network.inetAddresses.toList().any { it.address.contentEquals(address.address) }
|
||||
}
|
||||
}.getOrDefault(address is Inet4Address)
|
||||
|
||||
fun localConnectPort(context: Context): Int? =
|
||||
tlsPortProperty()
|
||||
?: findConnectPort(context, LOOPBACK)
|
||||
|
||||
private fun tlsPortProperty(): Int? = runCatching {
|
||||
Class.forName("android.os.SystemProperties").getMethod("get", String::class.java)
|
||||
.invoke(null, "service.adb.tls.port") as? String
|
||||
}.getOrNull()?.trim()?.toIntOrNull()?.takeIf { it in 1..65535 }
|
||||
|
||||
fun findConnectPort(context: Context, pairedHost: String, timeoutSeconds: Long = 12): Int? {
|
||||
val manager = context.applicationContext.getSystemService(Context.NSD_SERVICE) as NsdManager
|
||||
val expectedAddress = runCatching { InetAddress.getByName(pairedHost).address }.getOrNull() ?: return null
|
||||
val paired = runCatching { InetAddress.getByName(pairedHost) }.getOrNull() ?: return null
|
||||
val matches: (InetAddress) -> Boolean =
|
||||
if (paired.isLoopbackAddress) ::isLocalAddress else { address -> address.address.contentEquals(paired.address) }
|
||||
val resolvedPort = AtomicInteger(-1)
|
||||
val finished = AtomicBoolean(false)
|
||||
val latch = CountDownLatch(1)
|
||||
@@ -101,8 +122,8 @@ object WirelessAdbFix {
|
||||
}
|
||||
|
||||
override fun onServiceResolved(serviceInfo: NsdServiceInfo) {
|
||||
val address = serviceInfo.host?.address
|
||||
if (address != null && address.contentEquals(expectedAddress) && finished.compareAndSet(false, true)) {
|
||||
val address = serviceInfo.host
|
||||
if (address != null && matches(address) && finished.compareAndSet(false, true)) {
|
||||
resolvedPort.set(serviceInfo.port)
|
||||
latch.countDown()
|
||||
} else {
|
||||
@@ -148,14 +169,16 @@ object WirelessAdbFix {
|
||||
fun setChildProcessLimit(context: Context, host: String, port: Int, enabled: Boolean) {
|
||||
loadOrCreateIdentity(context.applicationContext)
|
||||
Kadb.create(host, port).use { adb ->
|
||||
val value = if (enabled) "true" else "false"
|
||||
val change = adb.shell("settings put global settings_enable_monitor_phantom_procs $value")
|
||||
check(change.exitCode == 0) { change.allOutput.ifBlank { "ADB command failed (${change.exitCode})" } }
|
||||
val result = adb.shell("settings get global settings_enable_monitor_phantom_procs")
|
||||
check(result.exitCode == 0 && result.output.trim() == value) {
|
||||
PhantomProcessLimit.shellCommands(enabled).forEach { command ->
|
||||
val change = adb.shell(command)
|
||||
check(change.exitCode == 0) { change.allOutput.ifBlank { "ADB command failed (${change.exitCode})" } }
|
||||
}
|
||||
val result = adb.shell(PhantomProcessLimit.verifyCommand())
|
||||
check(result.exitCode == 0 && PhantomProcessLimit.verified(result.output, enabled)) {
|
||||
"Android did not confirm the child-process limit was changed: ${result.allOutput.trim()}"
|
||||
}
|
||||
}
|
||||
if (PhantomProcessLimit.usesDeviceConfig()) PhantomProcessLimit.rememberAndroid12(context, !enabled)
|
||||
saveConnection(context, host, port)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
package com.droiddeck.launcher.core
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.RemoteInput
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.nsd.NsdManager
|
||||
import android.net.nsd.NsdServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import com.droiddeck.launcher.MainActivity
|
||||
import com.droiddeck.launcher.R
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
class WirelessAdbPairingService : Service() {
|
||||
sealed interface Stage {
|
||||
data object Idle : Stage
|
||||
data object Waiting : Stage
|
||||
data class CodeNeeded(val error: String? = null) : Stage
|
||||
data class Working(val step: String) : Stage
|
||||
data object Done : Stage
|
||||
data class Failed(val error: String) : Stage
|
||||
}
|
||||
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
private var nsd: NsdManager? = null
|
||||
private var discovery: NsdManager.DiscoveryListener? = null
|
||||
private var pairingHost: String? = null
|
||||
private var pairingPort: Int? = null
|
||||
@Volatile private var working = false
|
||||
private val timeout = Runnable { finish(Stage.Failed("Timed out waiting for the pairing pop-up. Try again from DroidDeck.")) }
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.action) {
|
||||
ACTION_CODE -> {
|
||||
val code = RemoteInput.getResultsFromIntent(intent)?.getCharSequence(KEY_CODE)
|
||||
?.filter(Char::isDigit)?.toString().orEmpty()
|
||||
onCode(code)
|
||||
}
|
||||
ACTION_CANCEL -> finish(Stage.Idle)
|
||||
else -> begin()
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
private fun begin() {
|
||||
pairingHost = null
|
||||
pairingPort = null
|
||||
working = false
|
||||
publish(Stage.Waiting)
|
||||
startForeground(NOTIFICATION_ID, notification(Stage.Waiting))
|
||||
startDiscovery()
|
||||
main.removeCallbacks(timeout)
|
||||
main.postDelayed(timeout, TIMEOUT_MS)
|
||||
}
|
||||
|
||||
private fun startDiscovery() {
|
||||
stopDiscovery()
|
||||
val manager = getSystemService(Context.NSD_SERVICE) as NsdManager
|
||||
val listener = object : NsdManager.DiscoveryListener {
|
||||
override fun onDiscoveryStarted(serviceType: String) = Unit
|
||||
override fun onServiceFound(serviceInfo: NsdServiceInfo) {
|
||||
if (serviceInfo.serviceType?.contains(PAIRING_TYPE) != true) return
|
||||
runCatching { manager.resolveService(serviceInfo, resolveListener()) }
|
||||
}
|
||||
override fun onServiceLost(serviceInfo: NsdServiceInfo) = Unit
|
||||
override fun onDiscoveryStopped(serviceType: String) = Unit
|
||||
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
|
||||
main.post { finish(Stage.Failed("Could not look for the pairing pop-up ($errorCode). Check that Wi-Fi is on.")) }
|
||||
}
|
||||
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) = Unit
|
||||
}
|
||||
runCatching { manager.discoverServices("$PAIRING_TYPE.", NsdManager.PROTOCOL_DNS_SD, listener) }
|
||||
.onSuccess { nsd = manager; discovery = listener }
|
||||
.onFailure { finish(Stage.Failed("Could not look for the pairing pop-up: ${it.localizedMessage}")) }
|
||||
}
|
||||
|
||||
private fun resolveListener() = object : NsdManager.ResolveListener {
|
||||
override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) = Unit
|
||||
override fun onServiceResolved(serviceInfo: NsdServiceInfo) {
|
||||
val address = serviceInfo.host ?: return
|
||||
if (!WirelessAdbFix.isLocalAddress(address)) return
|
||||
main.post {
|
||||
if (working || current.value == Stage.Done) return@post
|
||||
pairingHost = address.hostAddress
|
||||
pairingPort = serviceInfo.port
|
||||
if (current.value !is Stage.CodeNeeded) show(Stage.CodeNeeded())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onCode(code: String) {
|
||||
val host = pairingHost
|
||||
val port = pairingPort
|
||||
if (working) return
|
||||
if (host == null || port == null) {
|
||||
show(Stage.Waiting)
|
||||
return
|
||||
}
|
||||
if (code.length != 6) {
|
||||
show(Stage.CodeNeeded("Enter all 6 digits of the code"))
|
||||
return
|
||||
}
|
||||
working = true
|
||||
show(Stage.Working("Pairing…"))
|
||||
Thread({
|
||||
val paired = runCatching { kotlinx.coroutines.runBlocking { WirelessAdbFix.pair(this@WirelessAdbPairingService, WirelessAdbFix.LOOPBACK, port, code) } }
|
||||
if (paired.isFailure) {
|
||||
main.post {
|
||||
working = false
|
||||
pairingPort = null
|
||||
show(Stage.CodeNeeded("That code didn't work. Open the pairing pop-up again and enter its new code."))
|
||||
}
|
||||
return@Thread
|
||||
}
|
||||
main.post {
|
||||
stopDiscovery()
|
||||
show(Stage.Working("Paired. Connecting…"))
|
||||
}
|
||||
val result = runCatching {
|
||||
val connectPort = WirelessAdbFix.localConnectPort(this)
|
||||
?: error("Paired, but the Wireless debugging port was not found. Keep Wireless debugging on and try again.")
|
||||
main.post { show(Stage.Working("Applying the setting…")) }
|
||||
WirelessAdbFix.setChildProcessLimit(this, WirelessAdbFix.LOOPBACK, connectPort, false)
|
||||
}
|
||||
main.post {
|
||||
working = false
|
||||
finish(result.exceptionOrNull()?.let { Stage.Failed(it.localizedMessage ?: "Wireless debugging command failed") } ?: Stage.Done)
|
||||
}
|
||||
}, "wireless-adb-pairing").start()
|
||||
}
|
||||
|
||||
private fun show(stage: Stage) {
|
||||
publish(stage)
|
||||
getSystemService(NotificationManager::class.java)?.notify(NOTIFICATION_ID, notification(stage))
|
||||
}
|
||||
|
||||
private fun finish(stage: Stage) {
|
||||
main.removeCallbacks(timeout)
|
||||
stopDiscovery()
|
||||
publish(stage)
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
if (stage is Stage.Done || stage is Stage.Failed) {
|
||||
manager?.notify(NOTIFICATION_ID, notification(stage))
|
||||
stopForeground(STOP_FOREGROUND_DETACH)
|
||||
} else {
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
manager?.cancel(NOTIFICATION_ID)
|
||||
}
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
private fun stopDiscovery() {
|
||||
val listener = discovery ?: return
|
||||
runCatching { nsd?.stopServiceDiscovery(listener) }
|
||||
discovery = null
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
main.removeCallbacks(timeout)
|
||||
stopDiscovery()
|
||||
if (current.value.let { it is Stage.Waiting || it is Stage.CodeNeeded || it is Stage.Working }) publish(Stage.Idle)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun notification(stage: Stage): Notification {
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
manager?.createNotificationChannel(NotificationChannel(CHANNEL_ID, "Wireless debugging setup",
|
||||
NotificationManager.IMPORTANCE_HIGH).apply {
|
||||
description = "Asks for the Wireless debugging pairing code while Settings is open"
|
||||
setShowBadge(false)
|
||||
setSound(null, null)
|
||||
enableVibration(false)
|
||||
})
|
||||
val open = PendingIntent.getActivity(this, 0,
|
||||
Intent(this, MainActivity::class.java).setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT),
|
||||
PendingIntent.FLAG_IMMUTABLE)
|
||||
val cancel = PendingIntent.getService(this, 1,
|
||||
Intent(this, WirelessAdbPairingService::class.java).setAction(ACTION_CANCEL), PendingIntent.FLAG_IMMUTABLE)
|
||||
val builder = Notification.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_stat_session)
|
||||
.setContentIntent(open)
|
||||
.setShowWhen(false)
|
||||
.setCategory(Notification.CATEGORY_STATUS)
|
||||
when (stage) {
|
||||
Stage.Waiting, Stage.Idle -> builder
|
||||
.setContentTitle("Pair Wireless debugging")
|
||||
.setContentText("Turn on Wireless debugging, then tap “Pair device with pairing code”.")
|
||||
.setStyle(Notification.BigTextStyle().bigText(
|
||||
"Turn on Wireless debugging, then tap “Pair device with pairing code”. Stay in Settings: the code goes in this notification."))
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.addAction(Notification.Action.Builder(null, "Cancel", cancel).build())
|
||||
is Stage.CodeNeeded -> {
|
||||
val reply = PendingIntent.getService(this, 2,
|
||||
Intent(this, WirelessAdbPairingService::class.java).setAction(ACTION_CODE),
|
||||
if (Build.VERSION.SDK_INT >= 31) PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
else PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
val input = RemoteInput.Builder(KEY_CODE).setLabel("6-digit pairing code").build()
|
||||
val text = stage.error ?: "Tap Enter code and type the 6 digits shown in Settings."
|
||||
builder
|
||||
.setContentTitle(if (stage.error == null) "Enter the Wi-Fi pairing code" else "Try the code again")
|
||||
.setContentText(text)
|
||||
.setStyle(Notification.BigTextStyle().bigText(text))
|
||||
.setOngoing(true)
|
||||
.addAction(Notification.Action.Builder(null, "Enter code", reply).addRemoteInput(input).build())
|
||||
.addAction(Notification.Action.Builder(null, "Cancel", cancel).build())
|
||||
}
|
||||
is Stage.Working -> builder
|
||||
.setContentTitle("Setting up Steam")
|
||||
.setContentText(stage.step)
|
||||
.setProgress(0, 0, true)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
Stage.Done -> builder
|
||||
.setContentTitle("Steam is ready")
|
||||
.setContentText("Tap to go back to DroidDeck. You can turn Wireless debugging off now.")
|
||||
.setStyle(Notification.BigTextStyle().bigText("Tap to go back to DroidDeck. You can turn Wireless debugging off now."))
|
||||
.setAutoCancel(true)
|
||||
is Stage.Failed -> builder
|
||||
.setContentTitle("Wireless debugging setup stopped")
|
||||
.setContentText(stage.error)
|
||||
.setStyle(Notification.BigTextStyle().bigText(stage.error))
|
||||
.setAutoCancel(true)
|
||||
}
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CHANNEL_ID = "wireless-adb-pairing"
|
||||
private const val NOTIFICATION_ID = 3
|
||||
private const val PAIRING_TYPE = "_adb-tls-pairing._tcp"
|
||||
private const val ACTION_CODE = "com.droiddeck.launcher.action.WIRELESS_ADB_CODE"
|
||||
private const val ACTION_CANCEL = "com.droiddeck.launcher.action.WIRELESS_ADB_CANCEL"
|
||||
private const val KEY_CODE = "code"
|
||||
private const val TIMEOUT_MS = 5 * 60_000L
|
||||
|
||||
private val current = MutableStateFlow<Stage>(Stage.Idle)
|
||||
val stage: StateFlow<Stage> = current
|
||||
|
||||
private fun publish(stage: Stage) { current.value = stage }
|
||||
|
||||
fun start(context: Context) {
|
||||
context.getSystemService(NotificationManager::class.java)?.cancel(NOTIFICATION_ID)
|
||||
publish(Stage.Waiting)
|
||||
val intent = Intent(context, WirelessAdbPairingService::class.java)
|
||||
if (Build.VERSION.SDK_INT >= 26) context.startForegroundService(intent) else context.startService(intent)
|
||||
}
|
||||
|
||||
fun cancel(context: Context) {
|
||||
if (current.value.let { it is Stage.Done || it is Stage.Failed || it is Stage.Idle }) {
|
||||
publish(Stage.Idle)
|
||||
return
|
||||
}
|
||||
context.startService(Intent(context, WirelessAdbPairingService::class.java).setAction(ACTION_CANCEL))
|
||||
}
|
||||
|
||||
fun notificationsEnabled(context: Context): Boolean =
|
||||
context.getSystemService(NotificationManager::class.java)?.areNotificationsEnabled() != false
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,15 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Environment
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import org.json.JSONObject
|
||||
import com.droiddeck.launcher.session.GameStorage
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import java.io.File
|
||||
import java.util.zip.CRC32
|
||||
|
||||
/**
|
||||
* The user's own Windows games, added to the Steam client's library.
|
||||
*
|
||||
* A Games folder is scanned one level deep: each subfolder is one game, and the program to launch
|
||||
* is the .exe in it that looks most like the game - the one named after the folder, else the
|
||||
* largest, never an installer, redistributable or crash reporter - unless the user picked one
|
||||
* for that game. Each game becomes a non-Steam shortcut in the client, under the ARM64 Proton,
|
||||
* with the appid the client itself would derive, so launching from the rail and from the
|
||||
* client's own library are the same thing. Any number of Games folders, from anywhere on the
|
||||
* device: each is bound into the session under /root/Games on its own.
|
||||
*/
|
||||
/** Windows game folders shared with the Steam session. */
|
||||
object AddedGames {
|
||||
private const val TAG = "AddedGames"
|
||||
|
||||
@@ -29,6 +20,7 @@ object AddedGames {
|
||||
/** What steam://rungameid/ takes for a shortcut. */
|
||||
val gameId: Long,
|
||||
val candidates: List<File>,
|
||||
val steamAppId: Int? = null,
|
||||
) {
|
||||
fun folderName(): String = folder.name
|
||||
}
|
||||
@@ -96,12 +88,15 @@ object AddedGames {
|
||||
|
||||
fun scan(context: Context): List<Game> {
|
||||
val out = ArrayList<Game>()
|
||||
for (root in roots(context)) {
|
||||
val dir = root.host
|
||||
val folders = roots(context).map { it.host } + listOfNotNull(
|
||||
GameStorage.effective(context)?.let { File(it.path) },
|
||||
GameStorage.effective(context)?.let { File(it.path, "steamapps/common") },
|
||||
)
|
||||
for (dir in folders.distinctBy { it.absolutePath }) {
|
||||
if (!dir.isDirectory) { Log.w(TAG, "$dir is not a folder; skipped"); continue }
|
||||
for (folder in dir.listFiles { f -> f.isDirectory }?.sortedBy { it.name.lowercase() } ?: emptyList()) scanGame(context, folder, out)
|
||||
}
|
||||
return out
|
||||
return out.distinctBy { it.folder.canonicalPath }
|
||||
}
|
||||
|
||||
private fun scanGame(context: Context, folder: File, out: MutableList<Game>) {
|
||||
@@ -115,10 +110,23 @@ object AddedGames {
|
||||
val name = folder.name
|
||||
val crc = CRC32().apply { update(("\"$guestExe\"" + name).toByteArray()) }.value
|
||||
val appId = crc or 0x80000000L
|
||||
out.add(Game(folder, name, exe, guestExe, guestDir, appId, (appId shl 32) or 0x02000000L, candidates))
|
||||
val steamRoot = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam")
|
||||
val steamId = steamRoute(steamRoot, appId)
|
||||
out.add(Game(folder, name, exe, guestExe, guestDir, appId,
|
||||
steamId?.toLong() ?: ((appId shl 32) or 0x02000000L), candidates, steamId))
|
||||
}
|
||||
}
|
||||
|
||||
private fun steamRoute(root: File, appId: Long): Int? = runCatching {
|
||||
val users = File(root, "config/loginusers.vdf").readText()
|
||||
val blocks = Regex(""""(\d{5,})"\s*\{([^}]*)\}""")
|
||||
val recent = blocks.findAll(users).firstOrNull { Regex(""""MostRecent"\s*"1"""").containsMatchIn(it.groupValues[2]) }
|
||||
?: return@runCatching null
|
||||
val account = recent.groupValues[1].toLong() - 76561197960265728L
|
||||
JSONObject(File(root, "userdata/$account/config/.droiddeck-routes.json").readText())
|
||||
.optInt(appId.toString()).takeIf { it > 0 }
|
||||
}.getOrNull()
|
||||
|
||||
/** The list the session hands the runtime's shortcuts writer; one file per session start. */
|
||||
fun writeListing(context: Context, games: List<Game>): File {
|
||||
val file = File(context.filesDir, "session/added-games.json").apply { parentFile?.mkdirs() }
|
||||
@@ -126,6 +134,7 @@ object AddedGames {
|
||||
games.forEachIndexed { i, g ->
|
||||
if (i > 0) json.append(',')
|
||||
json.append("{\"name\":").append(quote(g.name)).append(",\"exe\":").append(quote(g.guestExe))
|
||||
.append(",\"folder\":").append(quote(guestPath(context, g.folder) ?: g.guestDir))
|
||||
.append(",\"dir\":").append(quote(g.guestDir)).append(",\"appid\":").append(g.appId)
|
||||
// The art, as the session sees it: the app's cache is bound at its own path, a file in
|
||||
// the game's folder at the folder's guest path.
|
||||
@@ -145,5 +154,5 @@ object AddedGames {
|
||||
return guestPath(context, file)
|
||||
}
|
||||
|
||||
private fun quote(s: String) = "\"" + s.replace("\\", "\\\\").replace("\"", "\\\"") + "\""
|
||||
private fun quote(s: String): String = JSONObject.quote(s)
|
||||
}
|
||||
@@ -45,11 +45,11 @@ class OnScreenControls(context: Context, private val pad: PadBridge?, private va
|
||||
val halfW get() = if (wide) radius * SHOULDER_WIDTH else radius
|
||||
val halfH get() = if (wide) radius * SHOULDER_HEIGHT else radius
|
||||
|
||||
fun contains(x: Float, y: Float, r: Float): Boolean {
|
||||
fun contains(x: Float, y: Float, r: Float, padding: Float = 0f): Boolean {
|
||||
val dx = x - cx
|
||||
val dy = y - cy
|
||||
if (wide) return abs(dx) <= halfW + r * 0.25f && abs(dy) <= halfH + r * 0.25f
|
||||
val hit = r * 1.25f
|
||||
if (wide) return abs(dx) <= halfW + r * 0.25f + padding && abs(dy) <= halfH + r * 0.25f + padding
|
||||
val hit = r * 1.25f + padding
|
||||
return dx * dx + dy * dy <= hit * hit
|
||||
}
|
||||
|
||||
@@ -350,6 +350,7 @@ class OnScreenControls(context: Context, private val pad: PadBridge?, private va
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
for (control in controls) {
|
||||
if (!isVisible(control)) continue
|
||||
if (control.stick >= 0 && settings.adaptiveSticks && !editing && control.pressedBy == -1) continue
|
||||
val held = control.pressedBy != -1
|
||||
val radius = control.radius
|
||||
if (control.stick >= 0) {
|
||||
@@ -426,10 +427,13 @@ class OnScreenControls(context: Context, private val pad: PadBridge?, private va
|
||||
when (event.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN -> {
|
||||
val index = event.actionIndex
|
||||
val control = controlAt(event.getX(index), event.getY(index)) ?: return false
|
||||
val x = event.getX(index)
|
||||
val y = event.getY(index)
|
||||
val control = controlAt(x, y) ?: adaptiveStickAt(x, y) ?: return false
|
||||
if (control.pressedBy != -1) return true
|
||||
control.pressedBy = event.getPointerId(index)
|
||||
if (control.stick >= 0) {
|
||||
control.clicked = settings.stickClick && event.eventTime - control.lastUp < DOUBLE_TAP_MS
|
||||
control.clicked = settings.stickClick && control.lastUp > 0L && event.eventTime - control.lastUp < DOUBLE_TAP_MS
|
||||
val adaptive = settings.adaptiveSticks
|
||||
control.ax = if (adaptive) event.getX(index) else control.cx
|
||||
control.ay = if (adaptive) event.getY(index) else control.cy
|
||||
@@ -468,7 +472,7 @@ class OnScreenControls(context: Context, private val pad: PadBridge?, private va
|
||||
for (control in controls) {
|
||||
if (control.pressedBy == pointer || event.actionMasked == MotionEvent.ACTION_CANCEL) {
|
||||
if (control.pressedBy != -1) changed = true
|
||||
if (control.stick >= 0 && control.pressedBy != -1) control.lastUp = if (control.clicked) 0L else event.eventTime
|
||||
if (control.stick >= 0 && control.pressedBy != -1) control.lastUp = if (control.clicked || event.actionMasked == MotionEvent.ACTION_CANCEL) 0L else event.eventTime
|
||||
control.pressedBy = -1
|
||||
control.clicked = false
|
||||
if (control.stick >= 0 && (control.kx != 0f || control.ky != 0f)) control.dirty = true
|
||||
@@ -505,7 +509,25 @@ class OnScreenControls(context: Context, private val pad: PadBridge?, private va
|
||||
}
|
||||
|
||||
private fun controlAt(x: Float, y: Float): Control? =
|
||||
controls.firstOrNull { isVisible(it) && it.contains(x, y, it.radius) }
|
||||
controls.firstOrNull {
|
||||
isVisible(it) && (editing || !settings.adaptiveSticks || it.stick < 0) && it.contains(x, y, it.radius)
|
||||
}
|
||||
|
||||
private fun adaptiveStickAt(x: Float, y: Float): Control? {
|
||||
if (!settings.adaptiveSticks || editing || buttonsOnly) return null
|
||||
if (x < safe.left || x >= width - safe.right || y < safe.top || y >= height - safe.bottom) return null
|
||||
if (controls.any { it.stick < 0 && isVisible(it) && it.contains(x, y, it.radius, 5f) }) return null
|
||||
return controls.filter {
|
||||
val dx = x - it.cx
|
||||
val dy = y - it.cy
|
||||
val reach = it.radius * 1.4f * sqrt(1.5f)
|
||||
it.stick >= 0 && isVisible(it) && it.pressedBy == -1 && dx * dx + dy * dy <= reach * reach
|
||||
}.minByOrNull {
|
||||
val dx = x - it.cx
|
||||
val dy = y - it.cy
|
||||
dx * dx + dy * dy
|
||||
}
|
||||
}
|
||||
|
||||
private fun isVisible(control: Control): Boolean = when {
|
||||
buttonsOnly && !editing -> control.id == "guide" || control.id == "qam"
|
||||
@@ -574,6 +596,7 @@ class OnScreenControls(context: Context, private val pad: PadBridge?, private va
|
||||
controls.forEach {
|
||||
it.pressedBy = -1
|
||||
it.clicked = false
|
||||
it.lastUp = 0L
|
||||
if (it.stick >= 0 && (it.kx != 0f || it.ky != 0f)) it.dirty = true
|
||||
it.kx = 0f; it.ky = 0f
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import android.system.ErrnoException;
|
||||
import android.system.Os;
|
||||
import android.system.StructStat;
|
||||
import android.system.StructUtsname;
|
||||
import com.droiddeck.launcher.session.SessionPrefs;
|
||||
|
||||
|
||||
import java.io.File;
|
||||
@@ -33,7 +34,6 @@ public final class LinuxRuntime {
|
||||
/** Shortcut extra naming which of the modes above a Linux entry launches. */
|
||||
public static final String EXTRA_LINUX_MODE = "linux_mode";
|
||||
private static final String KGSL_DEVICE = "/dev/kgsl-3d0";
|
||||
private static final String GUEST_HOSTNAME = "DroidDeck";
|
||||
/** Where every Linux session's debug log lands: public, so a user can just hand the folder over. */
|
||||
public static final String DEBUG_LOG_DIR = "DroidDeck";
|
||||
|
||||
@@ -119,7 +119,7 @@ public final class LinuxRuntime {
|
||||
cmd.add(prootBinary(context).getPath());
|
||||
cmd.add("--kill-on-exit");
|
||||
// Preserve the host kernel identity while giving the guest the app's branded host name.
|
||||
cmd.add("--kernel-release=" + guestUtsname());
|
||||
cmd.add("--kernel-release=" + guestUtsname(SessionPrefs.guestHostname(context)));
|
||||
// Android's app seccomp policy traps the whole set*id family. Xwayland's Popen() calls
|
||||
// setgid()/setuid() before it execs xkbcomp and _exit(127)s when they fail, so without
|
||||
// this the keymap never compiles and Xwayland dies. -i makes proot answer those calls
|
||||
@@ -250,9 +250,9 @@ public final class LinuxRuntime {
|
||||
}
|
||||
|
||||
/** PRoot's complex -k format: sysname, nodename, release, version, machine, domain, HWCAP. */
|
||||
private static String guestUtsname() {
|
||||
private static String guestUtsname(String hostname) {
|
||||
StructUtsname host = Os.uname();
|
||||
return "\\" + host.sysname + "\\" + GUEST_HOSTNAME + "\\" + host.release
|
||||
return "\\" + host.sysname + "\\" + hostname + "\\" + host.release
|
||||
+ "\\" + host.version + "\\" + host.machine + "\\localdomain\\-1\\";
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AtomicFile
|
||||
import com.droiddeck.launcher.core.GameEnvironment
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
|
||||
object GameEnvironmentStore {
|
||||
private const val SETTINGS = "game-environment.json"
|
||||
private const val GUEST_FILE = "root/.config/droiddeck/game-environment.json"
|
||||
|
||||
@Synchronized
|
||||
fun read(context: Context): GameEnvironment.Config {
|
||||
val file = AtomicFile(File(context.filesDir, SETTINGS))
|
||||
val bytes = try { file.readFully() } catch (_: java.io.FileNotFoundException) { return GameEnvironment.Config() }
|
||||
return decode(JSONObject(bytes.toString(Charsets.UTF_8)))
|
||||
}
|
||||
|
||||
fun decode(json: JSONObject): GameEnvironment.Config {
|
||||
require(json.getInt("version") == 1)
|
||||
fun entries(value: JSONObject?): Map<String, String?> = buildMap {
|
||||
value?.keys()?.forEach { name ->
|
||||
require(GameEnvironment.validName(name))
|
||||
val entry = value.get(name)
|
||||
require(entry == JSONObject.NULL || entry is String && GameEnvironment.validValue(entry))
|
||||
put(name, if (entry == JSONObject.NULL) null else entry as String)
|
||||
}
|
||||
}
|
||||
val games = json.optJSONObject("games")
|
||||
return GameEnvironment.Config(entries(json.optJSONObject("shared")), buildMap {
|
||||
games?.keys()?.forEach { id ->
|
||||
require(id.isNotEmpty() && GameEnvironment.validScope(id))
|
||||
put(id, entries(games.getJSONObject(id)))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fun encode(config: GameEnvironment.Config): JSONObject {
|
||||
fun entries(values: Map<String, String?>) = JSONObject().apply {
|
||||
values.forEach { (key, value) -> put(key, value ?: JSONObject.NULL) }
|
||||
}
|
||||
return JSONObject().put("version", 1).put("shared", entries(config.shared)).put("games", JSONObject().apply {
|
||||
config.games.forEach { (id, values) -> put(id, entries(values)) }
|
||||
})
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun save(context: Context, config: GameEnvironment.Config) {
|
||||
val json = encode(config)
|
||||
decode(json)
|
||||
write(File(context.filesDir, SETTINGS), json.toString())
|
||||
publish(context, config)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun publish(context: Context, config: GameEnvironment.Config = read(context)) {
|
||||
val resolved = config.copy(shared = GameEnvironment.defaults(SessionPrefs.fexPreset(context)) + config.shared)
|
||||
write(File(LinuxRuntime.rootDir(context), GUEST_FILE), encode(resolved).toString())
|
||||
}
|
||||
|
||||
private fun write(file: File, text: String) {
|
||||
check(file.parentFile!!.isDirectory || file.parentFile!!.mkdirs())
|
||||
val atomic = AtomicFile(file)
|
||||
val output = atomic.startWrite()
|
||||
try {
|
||||
output.write(text.toByteArray(Charsets.UTF_8))
|
||||
atomic.finishWrite(output)
|
||||
} catch (error: Exception) {
|
||||
atomic.failWrite(output)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,9 @@ object SessionFiles {
|
||||
* rename, so a session that still has one mapped keeps the file it opened.
|
||||
*/
|
||||
fun stage(context: Context, root: File) {
|
||||
GameEnvironmentStore.publish(context)
|
||||
val files = arrayOf(
|
||||
"usr/local/bin/bannerlator-game-env" to "usr/local/bin/bannerlator-game-env",
|
||||
"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",
|
||||
@@ -38,6 +40,7 @@ object SessionFiles {
|
||||
"usr/local/bin/bannerlator-desktop-games" to "usr/local/bin/bannerlator-desktop-games",
|
||||
"usr/local/bin/bannerlator-steam-shim" to "usr/local/bin/bannerlator-steam-shim",
|
||||
"usr/local/bin/bannerlator-steam-shortcuts" to "usr/local/bin/bannerlator-steam-shortcuts",
|
||||
"usr/local/bin/bannerlator-steam-games" to "usr/local/bin/bannerlator-steam-games",
|
||||
"usr/local/bin/bannerlator-pad-defaults" to "usr/local/bin/bannerlator-pad-defaults",
|
||||
// The SteamOS helpers the client calls in Deck mode: the two Armada found it needs, plus
|
||||
// the three under /usr/bin, all no-ops that answer "nothing to do" (see each file).
|
||||
|
||||
@@ -211,6 +211,22 @@ object SessionPrefs {
|
||||
prefs(context).edit().putBoolean("prootNoSeccomp", on).apply()
|
||||
}
|
||||
|
||||
const val DEFAULT_GUEST_HOSTNAME = "DroidDeck"
|
||||
|
||||
@JvmStatic
|
||||
fun guestHostname(context: Context): String =
|
||||
validGuestHostname(prefs(context).getString("guestHostname", null)) ?: DEFAULT_GUEST_HOSTNAME
|
||||
|
||||
fun setGuestHostname(context: Context, name: String) {
|
||||
val valid = validGuestHostname(name)
|
||||
prefs(context).edit().apply { if (valid == null) remove("guestHostname") else putString("guestHostname", valid) }.apply()
|
||||
}
|
||||
|
||||
fun validGuestHostname(name: String?): String? {
|
||||
val trimmed = name?.trim() ?: return null
|
||||
return trimmed.takeIf { Regex("[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?").matches(it) }
|
||||
}
|
||||
|
||||
/** Turnip's sysmem rendering (TU_DEBUG=sysmem) for the runtime's driver: bypasses GMEM tiling. */
|
||||
fun tuSysmem(context: Context): Boolean = prefs(context).getBoolean("tuSysmem", false)
|
||||
|
||||
@@ -298,6 +314,8 @@ object SessionPrefs {
|
||||
|
||||
fun setFexPreset(context: Context, id: String) {
|
||||
prefs(context).edit().putString("fexPreset", id).apply()
|
||||
runCatching { GameEnvironmentStore.publish(context) }
|
||||
.onFailure { android.util.Log.e("GameEnvironment", "Could not update game environment", it) }
|
||||
}
|
||||
|
||||
/** The Steam client branch forced on the command line: "publicbeta" (every session so far) or "steamdeck_publicbeta" (Armada's). */
|
||||
|
||||
@@ -309,6 +309,7 @@ class SessionService : Service() {
|
||||
val added = com.droiddeck.launcher.frontend.AddedGames.scan(this)
|
||||
val listing = com.droiddeck.launcher.frontend.AddedGames.writeListing(this, added)
|
||||
guest.add("BL_ADDED_GAMES=" + listing.path)
|
||||
if (OfflineMode.enabled(this)) guest.add("BL_STEAM_OFFLINE=1")
|
||||
if (added.isNotEmpty()) Log.i(TAG, "added games: " + added.joinToString { "${it.name} (${it.exe.name})" })
|
||||
}
|
||||
// Where the guest leaves a request for another session (the desktop's Steam launchers).
|
||||
@@ -528,15 +529,6 @@ class SessionService : Service() {
|
||||
// XALIA_SUPPORTED_ONLY itself otherwise). Off by default: xalia is Valve's, and on a device
|
||||
// whose seccomp answers its syscalls normally there is no reason to take it away.
|
||||
if (SessionPrefs.noXalia(this)) guest.add("PROTON_USE_XALIA=0")
|
||||
// The FEXCore preset for the x86 games the client launches: its FEX_* variables go in
|
||||
// here, before the script, so every game process inherits them from the client. The
|
||||
// default preset sets nothing, which is what every session ran on before.
|
||||
if (steamHere) {
|
||||
val preset = SessionPrefs.fexPreset(this)
|
||||
val vars = com.droiddeck.launcher.core.FexPreset.env(preset)
|
||||
vars.forEach { guest.add(it) }
|
||||
if (vars.isNotEmpty()) Log.i(TAG, "fex preset $preset: ${vars.joinToString(" ")}")
|
||||
}
|
||||
// Anything else, for a device that cannot be reached with a debugger: Downloads/droiddeck-env
|
||||
// holds KEY=VALUE lines that go into the session's environment as written, after ours, so a
|
||||
// line here wins. Zink and Turnip tunables (ZINK_DESCRIPTORS=lazy, MESA_*), gamescope's,
|
||||
@@ -744,6 +736,24 @@ class SessionService : Service() {
|
||||
return try { Pair(fields[1].toInt(), fields[19].toLong()) } catch (e: NumberFormatException) { null }
|
||||
}
|
||||
|
||||
private fun askSteamToExit(prootPid: Int) {
|
||||
if (!File("/proc/$prootPid").exists()) return
|
||||
val request = File(LinuxRuntime.sessionRoot(this), "steam-stop")
|
||||
try {
|
||||
request.writeText("")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "could not ask the Steam client to exit", e)
|
||||
return
|
||||
}
|
||||
val started = System.currentTimeMillis()
|
||||
if (waitForExit(prootPid, STEAM_PICKUP_MS) || request.exists()) {
|
||||
request.delete()
|
||||
return
|
||||
}
|
||||
val exited = waitForExit(prootPid, STEAM_EXIT_MS)
|
||||
Log.i(TAG, "Steam client ${if (exited) "exited" else "did not exit"} after ${System.currentTimeMillis() - started} ms")
|
||||
}
|
||||
|
||||
private fun waitForExit(pid: Int, timeoutMs: Long): Boolean {
|
||||
val deadline = System.currentTimeMillis() + timeoutMs
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
@@ -961,9 +971,11 @@ class SessionService : Service() {
|
||||
}
|
||||
components.clear()
|
||||
FakeInputWriter.releaseAllRingSlots()
|
||||
val steamClientMayRun = SessionState.mode == MODE_STEAM || SessionState.mode == MODE_DESKTOP
|
||||
val finishAfterTeardown: () -> Unit = {
|
||||
if (prootPid > 1 || auxiliary.isNotEmpty()) {
|
||||
Thread({
|
||||
if (prootPid > 1 && steamClientMayRun) askSteamToExit(prootPid)
|
||||
auxiliary.forEach { (pid, started) -> teardown(pid, started) }
|
||||
if (prootPid > 1) teardown(prootPid)
|
||||
killGuestLeftovers()
|
||||
@@ -1122,6 +1134,8 @@ class SessionService : Service() {
|
||||
"steamwebhelper", "linuxfs/opt/android-host/proot", "/libproot.so", "pulseaudio/libpulseaudio.so")
|
||||
/** How long proot gets to run its own cleanup before it is killed outright. */
|
||||
private const val GRACE_MS = 1200L
|
||||
private const val STEAM_PICKUP_MS = 1500L
|
||||
private const val STEAM_EXIT_MS = 10_000L
|
||||
private const val NO_PAD_SWITCH = "Download/droiddeck-no-pad"
|
||||
private const val PAD_LOG_SWITCH = "Download/droiddeck-pad-log"
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ fun ColumnScope.ControllerRows(host: MenuHost, oscMode: String, c: ControllerPre
|
||||
ChoiceRow(host, "controller-opacity", "Opacity", null, ControllerPrefs.opacities.map { it to "$it%" }, c.opacity, onPick = a.onOpacity)
|
||||
ChoiceRow(host, "controller-size", "Button size", "100% keeps the standard size", ControllerPrefs.sizes.map { it to "$it%" }, c.size, onPick = a.onSize)
|
||||
ToggleRow(host, "controller-stick-click", "Stick click", "Double-tap a stick and hold for L3 or R3", c.stickClick, onChange = a.onStickClick)
|
||||
ToggleRow(host, "controller-adaptive", "Adaptive sticks", "A stick centers where your thumb lands and returns home when you let go", c.adaptiveSticks, onChange = a.onAdaptiveSticks)
|
||||
ToggleRow(host, "controller-adaptive", "Adaptive sticks", "Sticks appear when touched near their saved positions and hide when released", c.adaptiveSticks, onChange = a.onAdaptiveSticks)
|
||||
SettingsRow("Layout", if (c.customLayout) "Custom positions saved" else "Placed for this screen's size and your grip") {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
SecondaryButton("Edit") { a.onEditLayout() }
|
||||
|
||||
@@ -157,6 +157,8 @@ class FrontEndActions(
|
||||
val onSetPhantomProcessLimit: (Boolean, (String?) -> Unit) -> Unit = { _, done -> done("Wireless debugging is unavailable") },
|
||||
val onCopyPhantomCommand: (Boolean) -> Unit = {},
|
||||
val onDismissPhantomGate: () -> Unit = {},
|
||||
val onStartWirelessAdbPairing: () -> Unit = {},
|
||||
val onOpenNotificationSettings: () -> Unit = {},
|
||||
val controller: ControllerActions? = null,
|
||||
)
|
||||
|
||||
@@ -469,7 +471,13 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
|
||||
status = s.phantomProcessStatus,
|
||||
onDismiss = a.onDismissPhantomGate,
|
||||
onOpenDeveloperOptions = requestDeveloperOptions,
|
||||
onSetUpWirelessAdb = { requestWirelessAdbFix(false) },
|
||||
onFixWithWirelessDebugging = {
|
||||
a.onStartWirelessAdbPairing()
|
||||
requestDeveloperOptions()
|
||||
},
|
||||
onEnterAddressManually = { requestWirelessAdbFix(false) },
|
||||
onCopyCommand = { a.onCopyPhantomCommand(false) },
|
||||
onOpenNotificationSettings = a.onOpenNotificationSettings,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.core.GameEnvironment
|
||||
import com.droiddeck.launcher.core.GameEnvironmentOptions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import com.droiddeck.launcher.frontend.Library
|
||||
import com.droiddeck.launcher.session.GameEnvironmentStore
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
fun GameEnvironmentRow(modifier: Modifier = Modifier) {
|
||||
var open by remember { mutableStateOf(false) }
|
||||
SettingsRow(stringResource(R.string.game_env_title), stringResource(R.string.game_env_hint)) {
|
||||
TextButton(onClick = { open = true }, modifier = modifier.controllerConfirm { open = true }) {
|
||||
Text(stringResource(R.string.game_env_edit))
|
||||
}
|
||||
}
|
||||
if (open) GameEnvironmentEditor { open = false }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GameEnvironmentEditor(onClose: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val coroutine = rememberCoroutineScope()
|
||||
var config by remember { mutableStateOf<GameEnvironment.Config?>(null) }
|
||||
var games by remember { mutableStateOf(emptyList<Pair<String, String>>()) }
|
||||
var scope by remember { mutableStateOf("") }
|
||||
var appId by remember { mutableStateOf("") }
|
||||
var scopeMenu by remember { mutableStateOf(false) }
|
||||
var addMenu by remember { mutableStateOf(false) }
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
var error by remember { mutableStateOf(false) }
|
||||
var editing by remember { mutableStateOf<Pair<String, String>?>(null) }
|
||||
val preset = SessionPrefs.fexPreset(context)
|
||||
LaunchedEffect(Unit) {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
GameEnvironmentStore.read(context) to Library.steamGames(context).map { it.appId.toString() to it.name }
|
||||
}
|
||||
}.onSuccess { (settings, installed) -> config = settings; games = installed }
|
||||
.onFailure { error = true }
|
||||
}
|
||||
fun save(next: GameEnvironment.Config) {
|
||||
busy = true
|
||||
coroutine.launch {
|
||||
val result = runCatching { withContext(Dispatchers.IO) { GameEnvironmentStore.save(context, next) } }
|
||||
result.onSuccess { config = next; error = false }.onFailure { error = true }
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
AlertDialog(
|
||||
onDismissRequest = { if (!busy) onClose() },
|
||||
title = { Text(stringResource(R.string.game_env_title)) },
|
||||
confirmButton = { TextButton(onClick = onClose, enabled = !busy) { Text(stringResource(R.string.game_env_close)) } },
|
||||
text = {
|
||||
Column(Modifier.heightIn(max = 460.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(stringResource(R.string.game_env_hint))
|
||||
Text(stringResource(R.string.game_env_defaults), style = MaterialTheme.typography.bodySmall)
|
||||
if (error) Text(stringResource(R.string.game_env_error), color = MaterialTheme.colorScheme.error)
|
||||
if (config == null && !error) Text(stringResource(R.string.game_env_loading))
|
||||
val current = config
|
||||
if (current != null) {
|
||||
val profileName = if (scope.isEmpty()) stringResource(R.string.game_env_shared)
|
||||
else games.firstOrNull { it.first == scope }?.second ?: stringResource(R.string.game_env_profile, scope)
|
||||
Text(stringResource(R.string.game_env_scope), style = MaterialTheme.typography.labelMedium)
|
||||
Box {
|
||||
TextButton(onClick = { scopeMenu = true }, enabled = !busy) { Text(profileName) }
|
||||
DropdownMenu(expanded = scopeMenu, onDismissRequest = { scopeMenu = false }, modifier = Modifier.heightIn(max = 280.dp)) {
|
||||
DropdownMenuItem(text = { Text(stringResource(R.string.game_env_shared)) }, onClick = { scope = ""; scopeMenu = false })
|
||||
val profiles = (games + current.games.keys.filter { id -> games.none { it.first == id } }.map { it to it }).distinctBy { it.first }
|
||||
for ((id, name) in profiles) DropdownMenuItem(text = { Text(name) }, onClick = { scope = id; scopeMenu = false })
|
||||
}
|
||||
}
|
||||
OutlinedTextField(value = appId, onValueChange = { appId = it }, singleLine = true, enabled = !busy,
|
||||
label = { Text(stringResource(R.string.game_env_app_id)) }, modifier = Modifier.fillMaxWidth())
|
||||
TextButton(onClick = { scope = appId.toLong().toString(); appId = "" }, enabled = !busy && appId.isNotEmpty() && GameEnvironment.validScope(appId)) {
|
||||
Text(stringResource(R.string.game_env_open))
|
||||
}
|
||||
Text(stringResource(R.string.game_env_overrides), style = MaterialTheme.typography.bodySmall)
|
||||
val entries = GameEnvironment.effective(current, preset, scope).toSortedMap()
|
||||
if (entries.isEmpty()) Text(stringResource(R.string.game_env_empty))
|
||||
for ((name, value) in entries) {
|
||||
HorizontalDivider()
|
||||
Text(name, style = MaterialTheme.typography.titleSmall)
|
||||
Text(value ?: stringResource(R.string.game_env_unset))
|
||||
if (!current.entries(scope).containsKey(name)) Text(stringResource(R.string.game_env_inherited), style = MaterialTheme.typography.labelSmall)
|
||||
Row {
|
||||
TextButton(onClick = { editing = name to (value ?: "") }, enabled = !busy) { Text(stringResource(R.string.game_env_edit)) }
|
||||
if (value != null) TextButton(onClick = { save(current.withEntries(scope, current.entries(scope) + (name to null))) }, enabled = !busy) {
|
||||
Text(stringResource(R.string.game_env_remove))
|
||||
}
|
||||
}
|
||||
if (current.entries(scope).containsKey(name)) TextButton(onClick = { save(current.withEntries(scope, current.entries(scope) - name)) }, enabled = !busy) {
|
||||
Text(stringResource(R.string.game_env_restore))
|
||||
}
|
||||
}
|
||||
Box {
|
||||
TextButton(onClick = { addMenu = true }, enabled = !busy) { Text(stringResource(R.string.game_env_add)) }
|
||||
DropdownMenu(expanded = addMenu, onDismissRequest = { addMenu = false }, modifier = Modifier.heightIn(max = 280.dp)) {
|
||||
DropdownMenuItem(text = { Text(stringResource(R.string.game_env_custom)) }, onClick = { editing = "" to ""; addMenu = false })
|
||||
for (option in GameEnvironmentOptions.all) DropdownMenuItem(text = { Text(option.name) }, onClick = { editing = option.name to option.value; addMenu = false })
|
||||
}
|
||||
}
|
||||
TextButton(onClick = { save(current.withEntries(scope, emptyMap())) }, enabled = !busy && current.entries(scope).isNotEmpty()) {
|
||||
Text(stringResource(R.string.game_env_reset))
|
||||
}
|
||||
}
|
||||
if (busy) LinearProgressIndicator(Modifier.fillMaxWidth())
|
||||
}
|
||||
},
|
||||
)
|
||||
editing?.let { (name, value) ->
|
||||
VariableDialog(name, value, onDismiss = { editing = null }) { key, content ->
|
||||
config?.let {
|
||||
val entries = it.entries(scope).toMutableMap()
|
||||
if (name.isNotEmpty() && name != key) entries[name] = null
|
||||
entries[key] = content
|
||||
save(it.withEntries(scope, entries))
|
||||
}
|
||||
editing = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VariableDialog(initialName: String, initialValue: String, onDismiss: () -> Unit, onSave: (String, String) -> Unit) {
|
||||
var name by remember { mutableStateOf(initialName) }
|
||||
var value by remember { mutableStateOf(initialValue) }
|
||||
val valid = GameEnvironment.validName(name) && GameEnvironment.validValue(value)
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.game_env_edit)) },
|
||||
confirmButton = { TextButton(onClick = { onSave(name, value) }, enabled = valid) { Text(stringResource(R.string.game_env_save)) } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.game_env_cancel)) } },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
VariableNamePicker(name) { selected ->
|
||||
name = selected
|
||||
value = GameEnvironmentOptions.find(selected)?.value.orEmpty()
|
||||
}
|
||||
VariableValueEditor(name, value) { value = it }
|
||||
GameEnvironmentOptions.find(name)?.takeIf { it.detail != 0 }?.let {
|
||||
Text(stringResource(it.detail), style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
if (!GameEnvironment.supported(name)) Text(stringResource(R.string.game_env_unused))
|
||||
if (!valid && name.isNotEmpty()) Text(stringResource(R.string.game_env_invalid), color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VariableNamePicker(name: String, onChange: (String) -> Unit) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
var custom by remember { mutableStateOf(name.isEmpty() || GameEnvironmentOptions.find(name) == null) }
|
||||
Text(stringResource(R.string.game_env_name), style = MaterialTheme.typography.labelMedium)
|
||||
Box {
|
||||
OutlinedButton(onClick = { expanded = true }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(if (custom) stringResource(R.string.game_env_custom) else name)
|
||||
}
|
||||
DropdownMenu(expanded, onDismissRequest = { expanded = false }, modifier = Modifier.heightIn(max = 280.dp)) {
|
||||
DropdownMenuItem(text = { Text(stringResource(R.string.game_env_custom)) }, onClick = {
|
||||
custom = true; onChange(""); expanded = false
|
||||
})
|
||||
for (option in GameEnvironmentOptions.all.sortedBy { it.name.uppercase(java.util.Locale.ROOT) }) {
|
||||
DropdownMenuItem(text = { Text(option.name) }, onClick = {
|
||||
custom = false; onChange(option.name); expanded = false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
if (custom) OutlinedTextField(value = name, onValueChange = onChange, singleLine = true,
|
||||
label = { Text(stringResource(R.string.game_env_name)) }, modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VariableValueEditor(name: String, value: String, onChange: (String) -> Unit) {
|
||||
val option = GameEnvironmentOptions.find(name)
|
||||
var expanded by remember(name) { mutableStateOf(false) }
|
||||
var custom by remember(name) { mutableStateOf(option == null || value !in option.choices && option.type != GameEnvironmentOptions.Type.MULTIPLE) }
|
||||
when {
|
||||
option?.type == GameEnvironmentOptions.Type.TOGGLE -> Row(
|
||||
Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
|
||||
) {
|
||||
Text(stringResource(R.string.game_env_value))
|
||||
Switch(checked = value == option.choices.last(), onCheckedChange = {
|
||||
onChange(if (it) option.choices.last() else option.choices.first())
|
||||
})
|
||||
}
|
||||
option != null && option.choices.isNotEmpty() -> {
|
||||
Box {
|
||||
OutlinedButton(onClick = { expanded = true }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(if (custom) stringResource(R.string.game_env_custom_value) else value.ifEmpty { stringResource(R.string.game_env_select_value) })
|
||||
}
|
||||
DropdownMenu(expanded, onDismissRequest = { expanded = false }, modifier = Modifier.heightIn(max = 280.dp)) {
|
||||
DropdownMenuItem(text = { Text(stringResource(R.string.game_env_custom_value)) }, onClick = { custom = true; expanded = false })
|
||||
for (choice in option.choices) {
|
||||
val selected = choice in value.split(',')
|
||||
DropdownMenuItem(text = { Text(choice) }, leadingIcon = {
|
||||
if (option.type == GameEnvironmentOptions.Type.MULTIPLE) Checkbox(checked = selected, onCheckedChange = null)
|
||||
}, onClick = {
|
||||
custom = false
|
||||
if (option.type == GameEnvironmentOptions.Type.MULTIPLE) {
|
||||
onChange(GameEnvironmentOptions.toggle(value, choice))
|
||||
} else {
|
||||
onChange(choice); expanded = false
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
if (custom) OutlinedTextField(value = value, onValueChange = onChange, singleLine = true,
|
||||
label = { Text(stringResource(R.string.game_env_value)) }, modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
else -> OutlinedTextField(value = value, onValueChange = onChange, singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = if (option?.type == GameEnvironmentOptions.Type.NUMBER) KeyboardType.Number else KeyboardType.Text),
|
||||
label = { Text(stringResource(R.string.game_env_value)) }, modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,8 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.droiddeck.launcher.R
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -347,13 +349,13 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
|
||||
for (dir in s.addedGamesDirs) {
|
||||
val n = s.addedGames.count { it.folderPath.startsWith("$dir/") }
|
||||
ActionRow(
|
||||
dir.substringAfterLast('/').ifEmpty { dir }, dir + " · " + (if (n == 0) "no game folders with a .exe found" else "$n game${if (n == 1) "" else "s"}") + ". Forget: the games leave the client's library at the next session start; nothing on disk is touched.",
|
||||
dir.substringAfterLast('/').ifEmpty { dir }, dir + " · " + (if (n == 0) "no game folders with a .exe found" else "$n game${if (n == 1) "" else "s"}") + ". " + stringResource(R.string.added_games_forget_hint),
|
||||
"Forget", onClick = { a.onForgetAddedGamesDir(dir) },
|
||||
)
|
||||
}
|
||||
ActionRow(
|
||||
if (s.addedGamesDirs.isEmpty()) "Games folder" else "Another games folder",
|
||||
"Your own Windows games, one subfolder each, anywhere: internal storage, the SD card, a USB drive. As many folders as you like. Each game goes into the client's library as a non-Steam game under the ARM64 Proton, at the next session start.",
|
||||
stringResource(R.string.added_games_import_hint),
|
||||
"Add…", onClick = a.onPickAddedGamesDir,
|
||||
)
|
||||
ToggleRow(
|
||||
@@ -368,12 +370,13 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
|
||||
onPick = { path -> if (path == "__pick__") a.onPickAddedGameExe(g.folderPath) else a.onAddedGameExe(g.folderPath, path) },
|
||||
)
|
||||
}
|
||||
if (steam && s.fexPreset != null) SettingsGroup("Games") {
|
||||
if (steam && s.fexPreset != null) SettingsGroup(stringResource(R.string.game_settings_title)) {
|
||||
ChoiceRow(
|
||||
host, "fex", "FEX preset", "Applies on next game launch.",
|
||||
FexPreset.all.map { it.id to it.label }, s.fexPreset,
|
||||
note = FexPreset.byId(s.fexPreset).detail, onPick = a.onFexPreset,
|
||||
host, "fex", stringResource(R.string.fex_preset_title), stringResource(R.string.fex_next_launch),
|
||||
FexPreset.all.map { it.id to stringResource(it.label) }, s.fexPreset,
|
||||
note = stringResource(FexPreset.byId(s.fexPreset).detail), onPick = a.onFexPreset,
|
||||
)
|
||||
GameEnvironmentRow()
|
||||
if (s.forceFullscreen != null) ToggleRow(
|
||||
host, "fill", "Stretch games to fill the screen",
|
||||
"Keeps games that resize their own window (FlatOut) full screen. Turn it off if a game shows up small in a corner (Quake 3). Applies next session.",
|
||||
@@ -400,7 +403,7 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
|
||||
val open = host.open == "storage"
|
||||
SettingsRow(
|
||||
"Second library",
|
||||
"Adds a library location in Steam. Applies next session.",
|
||||
stringResource(R.string.second_library_import_hint),
|
||||
highlighted = open,
|
||||
) {
|
||||
androidx.compose.foundation.layout.Box {
|
||||
|
||||
@@ -7,15 +7,22 @@ import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
|
||||
class CoreRow(val core: Int, val label: String)
|
||||
|
||||
@@ -32,6 +39,7 @@ fun PerformancePage(
|
||||
steamDeckMode: Boolean,
|
||||
noXalia: Boolean,
|
||||
prootNoSeccomp: Boolean,
|
||||
guestHostname: String,
|
||||
phantomWarning: String?,
|
||||
onClientOverride: (Boolean) -> Unit,
|
||||
onTuSysmem: (Boolean) -> Unit,
|
||||
@@ -41,6 +49,7 @@ fun PerformancePage(
|
||||
onSteamDeckMode: (Boolean) -> Unit,
|
||||
onNoXalia: (Boolean) -> Unit,
|
||||
onProotNoSeccomp: (Boolean) -> Unit,
|
||||
onGuestHostname: (String) -> Unit,
|
||||
onClientCore: (Int, Boolean) -> Unit,
|
||||
onGameCore: (Int, Boolean) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
@@ -50,7 +59,7 @@ fun PerformancePage(
|
||||
val coreItems = cores.map { it.core to it.label }
|
||||
SettingsPage(
|
||||
host, title = "Performance",
|
||||
lede = "CPU cores and session fixes. Applies next session.",
|
||||
lede = "CPU cores, session fixes and the host name. Applies next session.",
|
||||
onBack = onDismiss,
|
||||
) {
|
||||
SettingsGroup("Steam client cores") {
|
||||
@@ -110,6 +119,25 @@ fun PerformancePage(
|
||||
prootNoSeccomp, onChange = onProotNoSeccomp,
|
||||
)
|
||||
}
|
||||
SettingsGroup("Session identity") {
|
||||
var draft by remember(guestHostname) { mutableStateOf(guestHostname) }
|
||||
val valid = SessionPrefs.validGuestHostname(draft) != null
|
||||
SettingsRow(
|
||||
"Host name",
|
||||
if (valid || draft.isBlank()) "What the session and Steam report as this machine's name. Blank restores ${SessionPrefs.DEFAULT_GUEST_HOSTNAME}."
|
||||
else "Letters, digits and inner hyphens only, up to 63 characters. Not saved until it is valid.",
|
||||
) {
|
||||
OutlinedTextField(
|
||||
draft, { v ->
|
||||
draft = v.take(63)
|
||||
if (draft.isBlank() || SessionPrefs.validGuestHostname(draft) != null) onGuestHostname(draft)
|
||||
},
|
||||
singleLine = true, isError = !valid && draft.isNotBlank(),
|
||||
placeholder = { Text(SessionPrefs.DEFAULT_GUEST_HOSTNAME) },
|
||||
modifier = Modifier.width(220.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (phantomWarning != null) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Column(
|
||||
|
||||
@@ -1,64 +1,96 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import android.os.Build
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.droiddeck.launcher.core.PhantomProcessLimit
|
||||
import com.droiddeck.launcher.core.PhantomProcessStatus
|
||||
import com.droiddeck.launcher.core.WirelessAdbPairingService
|
||||
import com.droiddeck.launcher.core.WirelessAdbPairingService.Stage
|
||||
|
||||
@Composable
|
||||
fun PhantomProcessGatePage(
|
||||
status: PhantomProcessStatus,
|
||||
onDismiss: () -> Unit,
|
||||
onOpenDeveloperOptions: () -> Unit,
|
||||
onSetUpWirelessAdb: () -> Unit,
|
||||
onFixWithWirelessDebugging: () -> Unit,
|
||||
onEnterAddressManually: () -> Unit,
|
||||
onCopyCommand: () -> Unit,
|
||||
onOpenNotificationSettings: () -> Unit,
|
||||
) {
|
||||
BackHandler(onBack = onDismiss)
|
||||
val context = LocalContext.current
|
||||
val stage by WirelessAdbPairingService.stage.collectAsState()
|
||||
val environment by produceState(GateEnvironment.read(context)) {
|
||||
while (true) {
|
||||
value = GateEnvironment.read(context)
|
||||
kotlinx.coroutines.delay(2_000)
|
||||
}
|
||||
}
|
||||
val hasToggle = PhantomProcessLimit.hasDeveloperToggle()
|
||||
|
||||
BoxWithConstraints(Modifier.fillMaxWidth()) {
|
||||
val compact = maxWidth < 620.dp && maxHeight < 500.dp
|
||||
SettingsPage(
|
||||
host = rememberMenuHost(),
|
||||
title = if (compact) "Steam paused" else "Steam cannot start yet",
|
||||
title = "One Android setting to change",
|
||||
eyebrow = "Steam",
|
||||
lede = if (compact) compactStatus(status) else "${PhantomProcessLimit.title(status)} · checking again every two seconds",
|
||||
lede = if (compact) null else "Android stops apps that start lots of background processes, and Steam starts dozens. " +
|
||||
"Until this is changed, games close with no error.",
|
||||
onBack = onDismiss,
|
||||
// Scrolls, so the larger text never pushes the ADB command off a short screen.
|
||||
scrollContent = true,
|
||||
compactLayout = compact,
|
||||
) {
|
||||
BoxWithConstraints(Modifier.fillMaxWidth()) {
|
||||
val wide = maxWidth >= 620.dp
|
||||
Column(
|
||||
modifier = Modifier.widthIn(max = 900.dp).fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
when {
|
||||
compact -> CompactGateInstructions(status, onOpenDeveloperOptions, onSetUpWirelessAdb)
|
||||
wide -> Row(horizontalArrangement = Arrangement.spacedBy(14.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
GateInstructions(status, onOpenDeveloperOptions, onSetUpWirelessAdb)
|
||||
}
|
||||
Column(Modifier.weight(1f)) { AdbFallback() }
|
||||
}
|
||||
else -> {
|
||||
GateInstructions(status, onOpenDeveloperOptions, onSetUpWirelessAdb)
|
||||
AdbFallback()
|
||||
}
|
||||
val fix: @Composable ColumnScope.() -> Unit = {
|
||||
FixGroup(hasToggle, stage, environment, compact, onOpenDeveloperOptions, onFixWithWirelessDebugging,
|
||||
onOpenNotificationSettings, onCancel = { WirelessAdbPairingService.cancel(context) })
|
||||
}
|
||||
val side: @Composable ColumnScope.() -> Unit = {
|
||||
StatusGroup(status, compact)
|
||||
OtherWays(hasToggle, compact, onOpenDeveloperOptions, onFixWithWirelessDebugging, onEnterAddressManually, onCopyCommand)
|
||||
}
|
||||
if (wide) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(14.dp), modifier = Modifier.widthIn(max = 900.dp).fillMaxWidth()) {
|
||||
Column(Modifier.weight(1.2f), content = fix)
|
||||
Column(Modifier.weight(1f), content = side)
|
||||
}
|
||||
} else {
|
||||
Column(Modifier.widthIn(max = 600.dp).fillMaxWidth()) {
|
||||
fix()
|
||||
side()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,97 +98,168 @@ fun PhantomProcessGatePage(
|
||||
}
|
||||
}
|
||||
|
||||
private fun compactStatus(status: PhantomProcessStatus): String {
|
||||
val state = when (status) {
|
||||
PhantomProcessStatus.ENABLED -> "Child-process limit is on"
|
||||
PhantomProcessStatus.UNSET -> "Child-process limit is unset"
|
||||
PhantomProcessStatus.UNREADABLE -> "Child-process limit is unreadable"
|
||||
else -> PhantomProcessLimit.title(status)
|
||||
private data class GateEnvironment(val onWifi: Boolean, val notifications: Boolean) {
|
||||
companion object {
|
||||
fun read(context: Context): GateEnvironment {
|
||||
val connectivity = context.getSystemService(ConnectivityManager::class.java)
|
||||
val wifi = runCatching {
|
||||
connectivity?.getNetworkCapabilities(connectivity.activeNetwork)
|
||||
?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true
|
||||
}.getOrDefault(true)
|
||||
return GateEnvironment(wifi, WirelessAdbPairingService.notificationsEnabled(context))
|
||||
}
|
||||
}
|
||||
return "$state · retrying every 2 sec"
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CompactGateInstructions(
|
||||
status: PhantomProcessStatus,
|
||||
private fun FixGroup(
|
||||
hasToggle: Boolean,
|
||||
stage: Stage,
|
||||
environment: GateEnvironment,
|
||||
compact: Boolean,
|
||||
onOpenDeveloperOptions: () -> Unit,
|
||||
onSetUpWirelessAdb: () -> Unit,
|
||||
onFixWithWirelessDebugging: () -> Unit,
|
||||
onOpenNotificationSettings: () -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
) {
|
||||
SettingsGroup("1 · Developer options", compact = true) {
|
||||
val pairing = stage is Stage.Waiting || stage is Stage.CodeNeeded || stage is Stage.Working
|
||||
SettingsGroup(if (hasToggle) "Developer options" else "Fix it for me", compact = compact) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 7.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(5.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(if (compact) 10.dp else 14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
PhantomProcessLimit.gateInstructions(status),
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
PrimaryButton("Open Developer options", compact = true, onClick = onOpenDeveloperOptions)
|
||||
SecondaryButton("Use Wireless debugging", compact = true, onClick = onSetUpWirelessAdb)
|
||||
when {
|
||||
pairing -> PairingProgress(stage, onCancel)
|
||||
hasToggle -> {
|
||||
Body("Turn on “Disable child process restrictions”, then come back. Keep Developer options on afterwards: turning them off turns the switch back off.")
|
||||
PrimaryButton("Open Developer options", compact = compact, onClick = onOpenDeveloperOptions)
|
||||
}
|
||||
else -> {
|
||||
Body("This Android version has no switch for it, so DroidDeck changes it over Wireless debugging on this device. About a minute, no computer needed.")
|
||||
(stage as? Stage.Failed)?.let { Body(it.error, error = true) }
|
||||
PrimaryButton(
|
||||
if (stage is Stage.Failed) "Try again" else "Fix it for me",
|
||||
compact = compact,
|
||||
enabled = environment.onWifi && environment.notifications,
|
||||
onClick = onFixWithWirelessDebugging,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (!hasToggle && !pairing) {
|
||||
if (!environment.onWifi) {
|
||||
Body("Wireless debugging needs Wi-Fi. Connect to any network first; it doesn't need internet.", warn = true)
|
||||
}
|
||||
if (!environment.notifications) {
|
||||
Body("The pairing code is entered in a notification. Allow notifications for DroidDeck, or use Other ways.", warn = true)
|
||||
SecondaryButton("Allow notifications", compact = compact, onClick = onOpenNotificationSettings)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
SettingsGroup("3 · Computer ADB last resort", compact = true) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(3.dp),
|
||||
) {
|
||||
Text(
|
||||
"If the setting is missing and Wireless debugging is unavailable, run this from a computer:",
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
PhantomProcessLimit.ADB_COMMAND,
|
||||
style = MaterialTheme.typography.labelSmall.copy(fontFamily = FontFamily.Monospace, fontSize = 11.sp),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PairingProgress(stage: Stage, onCancel: () -> Unit) {
|
||||
val steps = listOf(
|
||||
"Open Settings → Developer options → Wireless debugging and turn it on",
|
||||
"Tap “Pair device with pairing code”",
|
||||
"Type the code into the DroidDeck notification",
|
||||
)
|
||||
val active = when (stage) {
|
||||
Stage.Waiting -> 1
|
||||
is Stage.CodeNeeded -> 2
|
||||
else -> 3
|
||||
}
|
||||
steps.forEachIndexed { index, step ->
|
||||
val done = index < active
|
||||
val current = index == active
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(if (done) "✓" else "${index + 1}", fontSize = 13.sp, fontWeight = FontWeight.SemiBold,
|
||||
color = if (done) LocalPalette.current.good else MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(step, style = MaterialTheme.typography.bodySmall,
|
||||
color = if (current) MaterialTheme.colorScheme.onBackground else MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
|
||||
Text(
|
||||
when (stage) {
|
||||
Stage.Waiting -> "Waiting for the pairing pop-up…"
|
||||
is Stage.CodeNeeded -> stage.error ?: "Pop-up found. Enter its code in the notification."
|
||||
is Stage.Working -> stage.step
|
||||
else -> ""
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (stage is Stage.CodeNeeded && stage.error != null) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
SecondaryButton("Cancel", compact = true, onClick = onCancel)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusGroup(status: PhantomProcessStatus, compact: Boolean) {
|
||||
SettingsGroup("Status", compact = compact) {
|
||||
Column(Modifier.fillMaxWidth().padding(if (compact) 10.dp else 14.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(PhantomProcessLimit.title(status), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onBackground)
|
||||
Body("Checking every 2 seconds. This page closes when it's off.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GateInstructions(
|
||||
status: PhantomProcessStatus,
|
||||
private fun OtherWays(
|
||||
hasToggle: Boolean,
|
||||
compact: Boolean,
|
||||
onOpenDeveloperOptions: () -> Unit,
|
||||
onSetUpWirelessAdb: () -> Unit,
|
||||
onFixWithWirelessDebugging: () -> Unit,
|
||||
onEnterAddressManually: () -> Unit,
|
||||
onCopyCommand: () -> Unit,
|
||||
) {
|
||||
SettingsGroup("1 · Developer options first") {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(14.dp),
|
||||
var open by rememberSaveable { mutableStateOf(false) }
|
||||
SettingsGroup("Other ways", compact = compact) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().clickable { open = !open }.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
) {
|
||||
Body(if (hasToggle) "Wireless debugging, manual address, computer ADB" else "Manual address, computer ADB, Developer options", modifier = Modifier.weight(1f))
|
||||
Text(if (open) "▴" else "▾", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
if (open) Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(start = 14.dp, end = 14.dp, bottom = 14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
if (hasToggle) {
|
||||
SecondaryButton("Use Wireless debugging instead", compact = true, onClick = onFixWithWirelessDebugging)
|
||||
} else {
|
||||
Body("Some ROMs add the switch anyway. Look for “child process” in Developer options.")
|
||||
SecondaryButton("Open Developer options", compact = true, onClick = onOpenDeveloperOptions)
|
||||
}
|
||||
Body("If the notification doesn't work, enter the pop-up's address and code yourself.")
|
||||
SecondaryButton("Enter address manually", compact = true, onClick = onEnterAddressManually)
|
||||
Body("From a computer with ADB:")
|
||||
Text(
|
||||
PhantomProcessLimit.gateInstructions(status),
|
||||
PhantomProcessLimit.adbCommand(),
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace, fontSize = 11.sp),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
PrimaryButton("Open Developer options", onClick = onOpenDeveloperOptions)
|
||||
SecondaryButton("Use Wireless debugging", onClick = onSetUpWirelessAdb)
|
||||
SecondaryButton("Copy command", compact = true, onClick = onCopyCommand)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AdbFallback() {
|
||||
SettingsGroup("3 · Computer ADB last resort") {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
"Use this only if Developer options has no setting and Wireless debugging is unavailable. Connect this device to a computer with ADB and run:",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
Text(
|
||||
PhantomProcessLimit.ADB_COMMAND,
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
private fun Body(text: String, modifier: Modifier = Modifier, error: Boolean = false, warn: Boolean = false) {
|
||||
Text(
|
||||
text,
|
||||
modifier = modifier,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = when {
|
||||
error -> MaterialTheme.colorScheme.error
|
||||
warn -> androidx.compose.ui.graphics.Color(0xFFFFB86B)
|
||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -89,6 +89,8 @@ import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.core.FexPreset
|
||||
import com.droiddeck.launcher.gpu.FrameGen
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
@@ -405,9 +407,12 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
|
||||
ChoiceRow(host, "shape", "Screen ratio", null,
|
||||
SessionPrefs.shapeChoices, a.shapeMode,
|
||||
chipModifier = focus.track(page, "shape"), onPick = a.onShape)
|
||||
if (a.steam) ChoiceRow(host, "fex", "FEX preset", null,
|
||||
FexPreset.all.map { it.id to it.label }, a.fexPreset,
|
||||
}
|
||||
if (a.steam) SettingsGroup(stringResource(R.string.game_settings_title)) {
|
||||
ChoiceRow(host, "fex", stringResource(R.string.fex_preset_title), stringResource(R.string.fex_next_launch),
|
||||
FexPreset.all.map { it.id to stringResource(it.label) }, a.fexPreset,
|
||||
chipModifier = focus.track(page, "fex"), onPick = a.onFexPreset)
|
||||
GameEnvironmentRow(modifier = focus.track(page, "game-env"))
|
||||
}
|
||||
SettingsGroup("Support") {
|
||||
SettingsRow("Session logs", "Send this session's logs with a bug report") {
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
<!--
|
||||
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" />
|
||||
android:viewportWidth="239.92" android:viewportHeight="239.92">
|
||||
<group android:translateX="55.98" android:translateY="8.1">
|
||||
<path android:fillColor="#FFFFFFFF" android:fillType="evenOdd"
|
||||
android:pathData="M-31.98,0 H63.97 C125.81,0 175.93,50.08 175.93,111.86 S125.81,223.72 63.97,223.72 H-31.98 Z M-7.83,111.86 a71.81,71.81 0 1,0 143.62,0 a71.81,71.81 0 1,0 -143.62,0 Z M8,111.86 a55.98,55.98 0 1,0 111.96,0 a55.98,55.98 0 1,0 -111.96,0 Z M56.055,0 h15.83 v40.49 h-15.83 Z M56.055,183.23 h15.83 v40.49 h-15.83 Z" />
|
||||
</group>
|
||||
</vector>
|
||||
@@ -19,4 +19,71 @@
|
||||
<string name="resume_session">Resume</string>
|
||||
<string name="stop_session">Stop session</string>
|
||||
<string name="frame_gen_title">Frame generation</string>
|
||||
<string name="added_games_import_hint">Choose Windows games with one subfolder per game. Steam checks ownership while signed in. Confirmed games use their Steam library entries after restarting Steam; other games stay as non-Steam shortcuts. Steam may verify or download files for imported games.</string>
|
||||
<string name="second_library_import_hint">Adds a Steam library and scans Windows game folders here and in steamapps/common. After the ownership check and a Steam restart, owned games use Steam entries; other games use non-Steam shortcuts.</string>
|
||||
<string name="added_games_forget_hint">Forget removes non-Steam shortcuts next session. Manage Steam installations in Steam. Files stay on disk.</string>
|
||||
<string name="game_env_title">Game environment</string>
|
||||
<string name="game_env_hint">Shared defaults and per-game overrides for games using Proton. Applies on the next game launch.</string>
|
||||
<string name="game_env_edit">Edit variables</string>
|
||||
<string name="game_env_shared">All Proton games</string>
|
||||
<string name="game_env_scope">Game profile</string>
|
||||
<string name="game_env_app_id">Steam app ID or non-Steam compatibility ID</string>
|
||||
<string name="game_env_open">Open profile</string>
|
||||
<string name="game_env_profile">Game %1$s</string>
|
||||
<string name="game_env_add">Add variable</string>
|
||||
<string name="game_env_name">Variable name</string>
|
||||
<string name="game_env_value">Value</string>
|
||||
<string name="game_env_save">Save</string>
|
||||
<string name="game_env_cancel">Cancel</string>
|
||||
<string name="game_env_close">Close</string>
|
||||
<string name="game_env_remove">Remove</string>
|
||||
<string name="game_env_restore">Restore inherited settings</string>
|
||||
<string name="game_env_reset">Reset this profile</string>
|
||||
<string name="game_env_unset">Unset for this profile</string>
|
||||
<string name="game_env_empty">No variables set. Driver and Proton defaults apply.</string>
|
||||
<string name="game_env_inherited">Inherited</string>
|
||||
<string name="game_env_custom">Custom variable</string>
|
||||
<string name="game_env_defaults">D3D12 feature level defaults to 12_2 and shader model to 6_9. Both can be changed or removed for each game.</string>
|
||||
<string name="game_env_error">Could not load or apply the settings. Check available storage and try again.</string>
|
||||
<string name="game_env_invalid">Use a valid variable name and a value without NUL characters (up to 8192 characters).</string>
|
||||
<string name="game_env_unused">The standard Linux Proton runtime does not use this variable; it may require a custom component.</string>
|
||||
<string name="game_env_loading">Loading…</string>
|
||||
<string name="game_env_overrides">Settings in this profile override shared settings and matching Steam launch-option variables. Remove unsets a variable; restore uses its inherited value.</string>
|
||||
<string name="game_env_feature">D3D12 feature-level override. 12_2 reports DirectX 12 Ultimate capabilities; unsupported features can fail. Leave unset for automatic detection.</string>
|
||||
<string name="game_env_shader">D3D12 shader-model override. 6_9 requires a sufficiently recent VKD3D and matching GPU features. Leave unset for automatic detection.</string>
|
||||
<string name="game_env_cache">Keep Mesa shader caching enabled to reduce repeated compilation. false enables caching.</string>
|
||||
<string name="game_env_cache_size">Maximum Mesa shader-cache size, such as 1G. Larger caches use more storage.</string>
|
||||
<string name="game_env_vkd3d_config">VKD3D options for D3D12. nodxr disables ray tracing; use only for a game that needs it.</string>
|
||||
<string name="game_env_proton_log">Set to 1 for a Proton diagnostic log. Logging can reduce performance and use storage.</string>
|
||||
<string name="game_env_wined3d">Set to 1 to use OpenGL-based WineD3D instead of DXVK for D3D9–11 compatibility testing.</string>
|
||||
<string name="game_env_xalia">Set to 0 to disable Proton’s gamepad-navigation helper for this game.</string>
|
||||
<string name="game_env_hud">DXVK overlay for D3D9–11. fps displays frame rate; unset hides it.</string>
|
||||
<string name="game_env_dxvk_config">DXVK settings separated by semicolons. Recent builds use dxvk.maxFrameRate for frame limiting; prefer the session frame limiter for ordinary play.</string>
|
||||
<string name="game_env_vkd3d_limit">D3D12 frame-rate limit. 0 disables the limit.</string>
|
||||
<string name="game_env_glthread">Mesa OpenGL threading. true can help CPU-limited OpenGL games; test per game.</string>
|
||||
<string name="fex_preset_title">FEX preset</string>
|
||||
<string name="fex_next_launch">Applies on the next game launch.</string>
|
||||
<string name="game_settings_title">Games</string>
|
||||
<string name="fex_default_label">FEX defaults</string>
|
||||
<string name="fex_default_detail">Uses FEX defaults.</string>
|
||||
<string name="fex_stability_label">Stability</string>
|
||||
<string name="fex_stability_detail">Safest, slowest settings.</string>
|
||||
<string name="fex_compatibility_label">Compatibility</string>
|
||||
<string name="fex_compatibility_detail">Full TSO with multiblock.</string>
|
||||
<string name="fex_intermediate_label">Intermediate</string>
|
||||
<string name="fex_intermediate_detail">Balanced TSO with reduced x87 precision.</string>
|
||||
<string name="fex_performance_label">Performance</string>
|
||||
<string name="fex_performance_detail">All TSO disabled; multiblock.</string>
|
||||
<string name="fex_performance_tso_label">Performance + TSO</string>
|
||||
<string name="fex_performance_tso_detail">Performance settings with main TSO enabled.</string>
|
||||
<string name="fex_extreme_label">Extreme</string>
|
||||
<string name="fex_extreme_detail">Disables self-modifying-code checks. JIT games and DRM may break.</string>
|
||||
<string name="fex_extreme_tso_label">Extreme + TSO</string>
|
||||
<string name="fex_extreme_tso_detail">Extreme settings with main TSO enabled.</string>
|
||||
<string name="fex_extreme_gn_label">Extreme-gn</string>
|
||||
<string name="fex_extreme_gn_detail">Performance with TSC and metadata hints. Try before Extreme.</string>
|
||||
<string name="fex_denuvo_label">Denuvo</string>
|
||||
<string name="fex_denuvo_detail">Performance with full code checks and the hypervisor bit hidden.</string>
|
||||
<string name="game_env_custom_value">Custom value</string>
|
||||
<string name="game_env_select_value">Select value</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.droiddeck.launcher.core
|
||||
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class GameEnvironmentTest {
|
||||
@Test fun defaultsIncludeRequestedGraphicsLevelsAndKeepCaching() {
|
||||
val values = GameEnvironment.defaults("")
|
||||
assertEquals("false", values["MESA_SHADER_CACHE_DISABLE"])
|
||||
assertEquals("12_2", values["VKD3D_FEATURE_LEVEL"])
|
||||
assertEquals("6_9", values["VKD3D_SHADER_MODEL"])
|
||||
}
|
||||
|
||||
@Test fun gameOverridesSharedAndPresetWithoutLeakingToOtherGames() {
|
||||
val config = GameEnvironment.Config(
|
||||
shared = mapOf("FEX_MULTIBLOCK" to "0", "VKD3D_FEATURE_LEVEL" to "12_2"),
|
||||
games = mapOf("42" to mapOf("FEX_MULTIBLOCK" to "1", "VKD3D_FEATURE_LEVEL" to null)),
|
||||
)
|
||||
val game = GameEnvironment.effective(config, "COMPATIBILITY", "42")
|
||||
assertEquals("1", game["FEX_MULTIBLOCK"])
|
||||
assertTrue(game.containsKey("VKD3D_FEATURE_LEVEL"))
|
||||
assertNull(game["VKD3D_FEATURE_LEVEL"])
|
||||
val other = GameEnvironment.effective(config, "COMPATIBILITY", "43")
|
||||
assertEquals("0", other["FEX_MULTIBLOCK"])
|
||||
assertEquals("12_2", other["VKD3D_FEATURE_LEVEL"])
|
||||
}
|
||||
|
||||
@Test fun graphicsDefaultsRespectExistingEditsAndRemovals() {
|
||||
val config = GameEnvironment.Config(shared = mapOf("VKD3D_FEATURE_LEVEL" to "12_0"))
|
||||
.withEntries("42", mapOf("VKD3D_SHADER_MODEL" to null))
|
||||
val game = GameEnvironment.effective(config, "", "42")
|
||||
assertEquals("12_0", game["VKD3D_FEATURE_LEVEL"])
|
||||
assertTrue(game.containsKey("VKD3D_SHADER_MODEL"))
|
||||
assertNull(game["VKD3D_SHADER_MODEL"])
|
||||
val restored = GameEnvironment.effective(config.withEntries("42", emptyMap()), "", "42")
|
||||
assertEquals("6_9", restored["VKD3D_SHADER_MODEL"])
|
||||
}
|
||||
|
||||
@Test fun multiSelectionPreservesCustomTokensAndRemovesToggledValues() {
|
||||
assertEquals("fps,custom_token,frametimes", GameEnvironmentOptions.toggle("fps,custom_token", "frametimes"))
|
||||
assertEquals("custom_token", GameEnvironmentOptions.toggle("fps,custom_token", "fps"))
|
||||
assertEquals("", GameEnvironmentOptions.toggle("fps", "fps"))
|
||||
assertEquals("fps", GameEnvironmentOptions.toggle("", "fps"))
|
||||
}
|
||||
|
||||
@Test fun switchingPresetDropsOldPresetOnlyVariables() {
|
||||
val config = GameEnvironment.Config()
|
||||
assertEquals("none", GameEnvironment.effective(config, "EXTREME", "")["FEX_SMCCHECKS"])
|
||||
assertFalse(GameEnvironment.effective(config, "COMPATIBILITY", "").containsKey("FEX_SMCCHECKS"))
|
||||
assertFalse(GameEnvironment.effective(config, "", "").containsKey("FEX_MULTIBLOCK"))
|
||||
}
|
||||
|
||||
@Test fun resettingOneProfilePreservesOtherProfiles() {
|
||||
val config = GameEnvironment.Config(shared = mapOf("CUSTOM" to "shared"))
|
||||
.withEntries("42", mapOf("CUSTOM" to "game"))
|
||||
.withEntries("43", mapOf("CUSTOM" to "other"))
|
||||
.withEntries("42", emptyMap())
|
||||
assertEquals("shared", GameEnvironment.effective(config, "", "42")["CUSTOM"])
|
||||
assertEquals("other", GameEnvironment.effective(config, "", "43")["CUSTOM"])
|
||||
assertFalse(config.games.containsKey("42"))
|
||||
}
|
||||
|
||||
@Test fun validationPreservesLiteralValuesAndRejectsInvalidKeys() {
|
||||
assertTrue(GameEnvironment.validName("mesa_glthread"))
|
||||
assertFalse(GameEnvironment.validName("X=Y"))
|
||||
assertFalse(GameEnvironment.validName("1VAR"))
|
||||
assertFalse(GameEnvironment.validName("VAR\n"))
|
||||
assertTrue(GameEnvironment.validValue("a=b 'quote' $(touch file) ; $"))
|
||||
assertTrue(GameEnvironment.validValue(""))
|
||||
assertFalse(GameEnvironment.validValue("bad\u0000value"))
|
||||
assertFalse(GameEnvironment.validValue("x".repeat(8193)))
|
||||
assertTrue(GameEnvironment.validScope("4294967295"))
|
||||
for (scope in listOf("0", "-1", "01", "+1", "4294967296", "../42")) assertFalse(GameEnvironment.validScope(scope))
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.droiddeck.launcher.core
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class PhantomProcessLimitTest {
|
||||
@@ -26,4 +28,33 @@ class PhantomProcessLimitTest {
|
||||
assertEquals(PhantomProcessStatus.UNSET, PhantomProcessLimit.status(null, null))
|
||||
assertEquals(PhantomProcessStatus.UNSET, PhantomProcessLimit.status("", ""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun android12UsesDeviceConfig() {
|
||||
assertEquals(
|
||||
listOf(
|
||||
"device_config set_sync_disabled_for_tests persistent",
|
||||
"device_config put activity_manager max_phantom_processes 2147483647",
|
||||
),
|
||||
PhantomProcessLimit.shellCommands(enabled = false, sdk = 31),
|
||||
)
|
||||
assertTrue(PhantomProcessLimit.verified("2147483647\n", enabled = false, sdk = 31))
|
||||
assertFalse(PhantomProcessLimit.verified("null", enabled = false, sdk = 31))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun android12LAndLaterUseTheFlag() {
|
||||
assertEquals(
|
||||
listOf("settings put global settings_enable_monitor_phantom_procs false"),
|
||||
PhantomProcessLimit.shellCommands(enabled = false, sdk = 33),
|
||||
)
|
||||
assertTrue(PhantomProcessLimit.verified("false", enabled = false, sdk = 34))
|
||||
assertFalse(PhantomProcessLimit.verified("null", enabled = false, sdk = 33))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun developerToggleStartsAtAndroid14() {
|
||||
assertFalse(PhantomProcessLimit.hasDeveloperToggle(33))
|
||||
assertTrue(PhantomProcessLimit.hasDeveloperToggle(34))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import kotlin.math.sqrt
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.widget.FrameLayout
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [28], manifest = Config.NONE, qualifiers = "mdpi")
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
class OnScreenControlsTest {
|
||||
private lateinit var context: Context
|
||||
private lateinit var view: OnScreenControls
|
||||
private var time = 1000L
|
||||
|
||||
@Before fun setUp() {
|
||||
context = RuntimeEnvironment.getApplication()
|
||||
context.getSharedPreferences("controller", Context.MODE_PRIVATE).edit().clear().commit()
|
||||
ControllerPrefs.setLayout(context, 1200, 800, mapOf("ls" to (0.45f to 0.5f), "rs" to (0.55f to 0.5f)))
|
||||
view = OnScreenControls(context, null)
|
||||
view.layout(0, 0, 1200, 800)
|
||||
}
|
||||
|
||||
@Test fun adaptiveStickHidesUntilTouchedAndReturnsToNeutralOnRelease() {
|
||||
val left = control("ls")
|
||||
assertEquals(0, pixel(value(left, "cx"), value(left, "cy")))
|
||||
assertTrue(touch(MotionEvent.ACTION_DOWN, 7 to (540f to 400f)))
|
||||
assertEquals(7, field(left, "pressedBy"))
|
||||
assertEquals(540f, value(left, "ax"), 0f)
|
||||
assertEquals(400f, value(left, "ay"), 0f)
|
||||
assertNotEquals(0, pixel(540f, 400f))
|
||||
touch(MotionEvent.ACTION_MOVE, 7 to (580f to 420f))
|
||||
assertTrue(value(left, "kx") > 0f)
|
||||
assertTrue(value(left, "ky") > 0f)
|
||||
touch(MotionEvent.ACTION_UP, 7 to (580f to 420f))
|
||||
assertEquals(-1, field(left, "pressedBy"))
|
||||
assertEquals(0f, value(left, "kx"), 0f)
|
||||
assertEquals(0f, value(left, "ky"), 0f)
|
||||
assertEquals(0, pixel(540f, 400f))
|
||||
}
|
||||
|
||||
@Test fun simultaneousSticksKeepTheirPointersAndReleaseIndependently() {
|
||||
val left = control("ls")
|
||||
val right = control("rs")
|
||||
touch(MotionEvent.ACTION_DOWN, 7 to (540f to 400f))
|
||||
touch(MotionEvent.ACTION_POINTER_DOWN or (1 shl 8), 7 to (540f to 400f), 9 to (660f to 400f))
|
||||
assertEquals(7, field(left, "pressedBy"))
|
||||
assertEquals(9, field(right, "pressedBy"))
|
||||
touch(MotionEvent.ACTION_POINTER_DOWN or (2 shl 8), 7 to (540f to 400f), 9 to (660f to 400f), 11 to (500f to 400f))
|
||||
assertEquals(7, field(left, "pressedBy"))
|
||||
touch(MotionEvent.ACTION_POINTER_UP or (2 shl 8), 7 to (540f to 400f), 9 to (660f to 400f), 11 to (500f to 400f))
|
||||
touch(MotionEvent.ACTION_MOVE, 7 to (700f to 400f), 9 to (500f to 400f))
|
||||
assertTrue(value(left, "kx") > 0f)
|
||||
assertTrue(value(right, "kx") < 0f)
|
||||
touch(MotionEvent.ACTION_POINTER_UP, 7 to (700f to 400f), 9 to (500f to 400f))
|
||||
assertEquals(-1, field(left, "pressedBy"))
|
||||
assertEquals(9, field(right, "pressedBy"))
|
||||
touch(MotionEvent.ACTION_CANCEL, 9 to (500f to 400f))
|
||||
for (stick in listOf(left, right)) {
|
||||
assertEquals(-1, field(stick, "pressedBy"))
|
||||
assertEquals(0f, value(stick, "kx"), 0f)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun cancelledTouchDoesNotBecomeAStickClick() {
|
||||
touch(MotionEvent.ACTION_DOWN, 1 to (540f to 400f))
|
||||
touch(MotionEvent.ACTION_CANCEL, 1 to (540f to 400f))
|
||||
touch(MotionEvent.ACTION_DOWN, 1 to (540f to 400f))
|
||||
assertEquals(false, field(control("ls"), "clicked"))
|
||||
touch(MotionEvent.ACTION_UP, 1 to (540f to 400f))
|
||||
touch(MotionEvent.ACTION_DOWN, 1 to (540f to 400f))
|
||||
assertEquals(true, field(control("ls"), "clicked"))
|
||||
}
|
||||
|
||||
@Test fun buttonsTakePriorityOverAdaptiveRegions() {
|
||||
val button = control("a")
|
||||
touch(MotionEvent.ACTION_DOWN, 3 to (value(button, "cx") to value(button, "cy")))
|
||||
assertEquals(3, field(button, "pressedBy"))
|
||||
assertEquals(-1, field(control("rs"), "pressedBy"))
|
||||
}
|
||||
|
||||
@Test fun fixedSticksAndEditorRemainVisibleAtSavedPositions() {
|
||||
ControllerPrefs.setAdaptiveSticks(context, false)
|
||||
view.reload()
|
||||
val left = control("ls")
|
||||
val x = value(left, "cx")
|
||||
val y = value(left, "cy")
|
||||
assertNotEquals(0, pixel(x, y))
|
||||
assertFalse(touch(MotionEvent.ACTION_DOWN, 1 to (600f to 200f)))
|
||||
touch(MotionEvent.ACTION_DOWN, 1 to (x + 10f to y))
|
||||
assertEquals(x, value(left, "ax"), 0f)
|
||||
assertEquals(10f, value(left, "kx"), 0f)
|
||||
ControllerPrefs.setAdaptiveSticks(context, true)
|
||||
view = OnScreenControls(context, null, editing = true)
|
||||
view.layout(0, 0, 1200, 800)
|
||||
assertNotEquals(0, pixel(value(control("ls"), "cx"), value(control("ls"), "cy")))
|
||||
}
|
||||
|
||||
@Test fun buttonsOnlyAndReloadReleaseAdaptiveSticks() {
|
||||
touch(MotionEvent.ACTION_DOWN, 1 to (540f to 400f))
|
||||
touch(MotionEvent.ACTION_MOVE, 1 to (570f to 400f))
|
||||
view.reload()
|
||||
assertEquals(-1, field(control("ls"), "pressedBy"))
|
||||
assertEquals(0f, value(control("ls"), "kx"), 0f)
|
||||
view.setButtonsOnly(true)
|
||||
assertFalse(touch(MotionEvent.ACTION_DOWN, 1 to (600f to 200f)))
|
||||
}
|
||||
|
||||
@Test fun adaptiveActivationIsLimitedToExpandedSavedCircle() {
|
||||
val left = control("ls")
|
||||
val x = value(left, "cx")
|
||||
val y = value(left, "cy")
|
||||
val reach = value(left, "radius") * 1.4f * sqrt(1.5f)
|
||||
assertFalse(touch(MotionEvent.ACTION_DOWN, 1 to (x to y - reach - 1f)))
|
||||
assertTrue(touch(MotionEvent.ACTION_DOWN, 1 to (x to y - reach + 1f)))
|
||||
assertEquals(y - reach + 1f, value(left, "ay"), 0.001f)
|
||||
touch(MotionEvent.ACTION_UP, 1 to (x to y - reach + 1f))
|
||||
assertFalse(touch(MotionEvent.ACTION_DOWN, 1 to (x - reach to y - reach)))
|
||||
assertFalse(touch(MotionEvent.ACTION_DOWN, 1 to (600f to 200f)))
|
||||
}
|
||||
|
||||
@Test fun touchesOutsideActivationAreasStayWithTrackpad() {
|
||||
val received = mutableListOf<Int>()
|
||||
val trackpad = View(context).apply {
|
||||
setOnTouchListener { _, event -> received.add(event.actionMasked); true }
|
||||
}
|
||||
val root = FrameLayout(context).apply {
|
||||
addView(trackpad, FrameLayout.LayoutParams(1200, 800))
|
||||
addView(view, FrameLayout.LayoutParams(1200, 800))
|
||||
measure(View.MeasureSpec.makeMeasureSpec(1200, View.MeasureSpec.EXACTLY), View.MeasureSpec.makeMeasureSpec(800, View.MeasureSpec.EXACTLY))
|
||||
layout(0, 0, 1200, 800)
|
||||
}
|
||||
for ((action, position) in listOf(
|
||||
MotionEvent.ACTION_DOWN to (600f to 200f),
|
||||
MotionEvent.ACTION_MOVE to (540f to 400f),
|
||||
MotionEvent.ACTION_UP to (540f to 400f),
|
||||
)) {
|
||||
val event = MotionEvent.obtain(1000L, time++, action, position.first, position.second, 0)
|
||||
try { assertTrue(root.dispatchTouchEvent(event)) } finally { event.recycle() }
|
||||
}
|
||||
assertEquals(listOf(MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE, MotionEvent.ACTION_UP), received)
|
||||
assertEquals(-1, field(control("ls"), "pressedBy"))
|
||||
}
|
||||
|
||||
@Test fun adaptiveActivationLeavesFivePixelsAroundButtonHitTargets() {
|
||||
for (id in listOf("a", "rb")) {
|
||||
val button = control(id)
|
||||
val radius = value(button, "radius")
|
||||
val edge = value(button, "cy") - radius * if (id == "rb") 1.05f else 1.25f
|
||||
val x = value(button, "cx")
|
||||
val left = control("ls")
|
||||
left.javaClass.getDeclaredField("cx").apply { isAccessible = true }.setFloat(left, x)
|
||||
left.javaClass.getDeclaredField("cy").apply { isAccessible = true }.setFloat(left, edge - 10f)
|
||||
assertFalse(touch(MotionEvent.ACTION_DOWN, 1 to (x to edge - 4f)))
|
||||
assertEquals(-1, field(left, "pressedBy"))
|
||||
assertTrue(touch(MotionEvent.ACTION_DOWN, 1 to (x to edge - 6f)))
|
||||
assertEquals(1, field(left, "pressedBy"))
|
||||
touch(MotionEvent.ACTION_UP, 1 to (x to edge - 6f))
|
||||
}
|
||||
}
|
||||
|
||||
private fun control(id: String): Any = (field(view, "controls") as List<*>).first { field(it!!, "id") == id }!!
|
||||
private fun field(owner: Any, name: String): Any = owner.javaClass.getDeclaredField(name).apply { isAccessible = true }.get(owner)!!
|
||||
private fun value(owner: Any, name: String) = field(owner, name) as Float
|
||||
|
||||
private fun pixel(x: Float, y: Float): Int {
|
||||
val bitmap = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888)
|
||||
view.draw(Canvas(bitmap))
|
||||
val color = bitmap.getPixel(x.toInt(), y.toInt())
|
||||
bitmap.recycle()
|
||||
return color
|
||||
}
|
||||
|
||||
private fun touch(action: Int, vararg pointers: Pair<Int, Pair<Float, Float>>): Boolean {
|
||||
val properties = pointers.map { (id, _) -> MotionEvent.PointerProperties().apply { this.id = id; toolType = MotionEvent.TOOL_TYPE_FINGER } }.toTypedArray()
|
||||
val coords = pointers.map { (_, position) -> MotionEvent.PointerCoords().apply { x = position.first; y = position.second; pressure = 1f; size = 1f } }.toTypedArray()
|
||||
val event = MotionEvent.obtain(1000L, time++, action, pointers.size, properties, coords, 0, 0, 1f, 1f, 0, 0, 0, 0)
|
||||
return try { view.onTouchEvent(event) } finally { event.recycle() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.content.Context
|
||||
import com.droiddeck.launcher.core.GameEnvironment
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
import java.io.File
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [28], manifest = Config.NONE)
|
||||
class GameEnvironmentStoreTest {
|
||||
private lateinit var context: Context
|
||||
private lateinit var guest: File
|
||||
|
||||
@Before fun setUp() {
|
||||
context = RuntimeEnvironment.getApplication()
|
||||
guest = File(LinuxRuntime.rootDir(context), "root/.config/droiddeck/game-environment.json")
|
||||
File(context.filesDir, "game-environment.json").delete()
|
||||
}
|
||||
|
||||
@Test fun savedSettingsAndPublishedSettingsPreserveUnsetsAndLiteralValues() {
|
||||
val config = GameEnvironment.Config(
|
||||
shared = mapOf("LITERAL" to "a=b 'quoted' $(echo test)", "EMPTY" to ""),
|
||||
games = mapOf("42" to mapOf("LITERAL" to null, "VKD3D_SHADER_MODEL" to "6_9")),
|
||||
)
|
||||
GameEnvironmentStore.save(context, config)
|
||||
assertEquals(config, GameEnvironmentStore.read(context))
|
||||
val published = GameEnvironmentStore.decode(JSONObject(guest.readText()))
|
||||
assertEquals("false", published.shared["MESA_SHADER_CACHE_DISABLE"])
|
||||
assertEquals("12_2", published.shared["VKD3D_FEATURE_LEVEL"])
|
||||
assertEquals("6_9", published.shared["VKD3D_SHADER_MODEL"])
|
||||
assertEquals(config.games, published.games)
|
||||
assertEquals(config.shared["LITERAL"], published.shared["LITERAL"])
|
||||
assertEquals("", published.shared["EMPTY"])
|
||||
}
|
||||
|
||||
@Test fun changingFexPresetPublishesForNextGameLaunch() {
|
||||
GameEnvironmentStore.save(context, GameEnvironment.Config())
|
||||
SessionPrefs.setFexPreset(context, "EXTREME")
|
||||
assertEquals("none", JSONObject(guest.readText()).getJSONObject("shared").getString("FEX_SMCCHECKS"))
|
||||
SessionPrefs.setFexPreset(context, "")
|
||||
assertFalse(JSONObject(guest.readText()).getJSONObject("shared").has("FEX_SMCCHECKS"))
|
||||
}
|
||||
|
||||
@Test fun invalidDataCannotReplaceSavedConfiguration() {
|
||||
val original = GameEnvironment.Config(shared = mapOf("CUSTOM" to "ok"))
|
||||
GameEnvironmentStore.save(context, original)
|
||||
assertTrue(runCatching { GameEnvironmentStore.save(context, GameEnvironment.Config(shared = mapOf("BAD=NAME" to "x"))) }.isFailure)
|
||||
assertEquals(original, GameEnvironmentStore.read(context))
|
||||
}
|
||||
|
||||
@Test fun customComponentVariablesRemainEditable() {
|
||||
val config = GameEnvironment.Config(shared = mapOf("DXVK_ASYNC" to "1"))
|
||||
GameEnvironmentStore.save(context, config)
|
||||
assertEquals(config, GameEnvironmentStore.read(context))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class GuestHostnameTest {
|
||||
@Test fun validLabelsAreKept() {
|
||||
assertEquals("localhost", SessionPrefs.validGuestHostname("localhost"))
|
||||
assertEquals("Fold8-Steam", SessionPrefs.validGuestHostname("Fold8-Steam"))
|
||||
assertEquals("a", SessionPrefs.validGuestHostname("a"))
|
||||
assertEquals("x".repeat(63), SessionPrefs.validGuestHostname("x".repeat(63)))
|
||||
}
|
||||
|
||||
@Test fun surroundingSpaceIsTrimmed() {
|
||||
assertEquals("localhost", SessionPrefs.validGuestHostname(" localhost \n"))
|
||||
}
|
||||
|
||||
@Test fun anythingElseIsRejected() {
|
||||
assertNull(SessionPrefs.validGuestHostname(null))
|
||||
assertNull(SessionPrefs.validGuestHostname(""))
|
||||
assertNull(SessionPrefs.validGuestHostname(" "))
|
||||
assertNull(SessionPrefs.validGuestHostname("-leading"))
|
||||
assertNull(SessionPrefs.validGuestHostname("trailing-"))
|
||||
assertNull(SessionPrefs.validGuestHostname("two words"))
|
||||
assertNull(SessionPrefs.validGuestHostname("dotted.name"))
|
||||
assertNull(SessionPrefs.validGuestHostname("back\\slash"))
|
||||
assertNull(SessionPrefs.validGuestHostname("x".repeat(64)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
# Steam game environment
|
||||
|
||||
Open **Games → Game environment**, directly below **FEX preset**, in Steam
|
||||
settings or the in-session drawer. Choose all Proton games, an installed game, or enter a Steam app ID. For a
|
||||
non-Steam shortcut, use its numeric `compatdata` directory ID (unsigned 32-bit).
|
||||
Changes apply on the next game launch, including when Steam is already running.
|
||||
A running game must be restarted. Native Linux games and Steam itself are outside
|
||||
this editor's scope.
|
||||
|
||||
The order is inherited process environment, selected FEX preset and built-in
|
||||
settings, shared edits, then game-specific edits. Editor entries therefore win
|
||||
over matching Steam launch-option variables. **Remove** explicitly unsets a
|
||||
variable. **Restore inherited settings** removes the override. **Reset this
|
||||
profile** clears only that profile's overrides. Values are literal strings, with
|
||||
no shell expansion; quotes are only needed when the consuming program expects them.
|
||||
|
||||
## Defaults and available suggestions
|
||||
|
||||
The initial configuration enables Mesa shader caching with
|
||||
`MESA_SHADER_CACHE_DISABLE=false`, sets `VKD3D_FEATURE_LEVEL=12_2` and
|
||||
`VKD3D_SHADER_MODEL=6_9`, and preserves the selected FEX preset. Existing edits
|
||||
and explicit removals take precedence over these defaults. FEX's own default
|
||||
remains the default preset. Shader caching in VKD3D/DXVK, synchronization, ray
|
||||
tracing and diagnostic logging otherwise retain the runtime's defaults.
|
||||
|
||||
The variable-name picker offers 24 predefined variables and a Custom entry. Like
|
||||
WinNative, known variables use toggles, value dropdowns, multi-select lists, or
|
||||
numeric/text fields. Feature-level and shader-model dropdowns also accept custom
|
||||
values. Custom variables have editable names and literal values.
|
||||
|
||||
| Variable | Use |
|
||||
| --- | --- |
|
||||
| `VKD3D_FEATURE_LEVEL` | D3D12 capability override; defaults to `12_2`. |
|
||||
| `VKD3D_SHADER_MODEL` | Shader-model override; defaults to `6_9`. |
|
||||
| `VKD3D_CONFIG` | Per-game workarounds such as `nodxr`; not a universal performance preset. |
|
||||
| `MESA_SHADER_CACHE_MAX_SIZE` | Storage budget for Mesa's shader cache, for example `1G`. |
|
||||
| `mesa_glthread` | OpenGL threading; test with the affected game. |
|
||||
| `DXVK_CONFIG` | DXVK configuration options; recent builds accept `dxvk.maxFrameRate = 60`. |
|
||||
| `VKD3D_FRAME_RATE` | D3D12 frame-rate limit. |
|
||||
| `DXVK_HUD`, `PROTON_LOG` | Optional diagnostics; leave unset for ordinary play. |
|
||||
| `PROTON_USE_WINED3D` | D3D9–11 OpenGL fallback for compatibility testing. |
|
||||
| `PROTON_USE_XALIA` | Proton's gamepad-navigation helper toggle. |
|
||||
|
||||
Forcing `12_2` or `6_9` changes reported capabilities; it cannot implement a missing
|
||||
Vulkan feature. Remove either entry to use automatic detection for that capability.
|
||||
Other picker suggestions are editable starting values and are enabled only when
|
||||
added and saved. Older or
|
||||
custom components can support a different set of options. Android Wine wrapper,
|
||||
ALSA-server, Box64 and patched async-DXVK options are identified in the editor as
|
||||
requiring a different/custom component; custom entries remain allowed. The old
|
||||
`DXVK_FRAME_RATE` environment variable was removed from current DXVK, so it is
|
||||
not offered as a suggestion. Prefer the session frame limiter for ordinary play.
|
||||
|
||||
## Launch path and references
|
||||
|
||||
WinNative's Linux session filters Android-only options, merges user variables
|
||||
over the FEX preset, and passes the result to `env -i` before starting Steam. Its
|
||||
Windows/Wine graphics helper also maps the selected feature level to
|
||||
`VKD3D_FEATURE_LEVEL`. Reviewed source: [WinNative Linux session](https://github.com/maxjivi05/WinNative/blob/c9fbbb342f2689c852046804f4f5c9afa45b5dcb/app/src/main/runtime/display/XServerDisplayActivity.java),
|
||||
[variable editor](https://github.com/maxjivi05/WinNative/blob/c9fbbb342f2689c852046804f4f5c9afa45b5dcb/app/src/main/shared/ui/widget/EnvVarsView.java),
|
||||
[graphics configuration](https://github.com/maxjivi05/WinNative/blob/c9fbbb342f2689c852046804f4f5c9afa45b5dcb/app/src/main/feature/settings/drivers/DXVKConfigUtils.java).
|
||||
|
||||
DroidDeck instead publishes an atomic JSON snapshot at
|
||||
`/root/.config/droiddeck/game-environment.json`. Both its Valve ARM64 Proton
|
||||
wrapper and adopted third-party Proton wrappers execute `bannerlator-game-env`,
|
||||
which reads that snapshot for each real game launch and uses `execvpe` to start
|
||||
Proton. Probe prefix `compatdata/0` and non-launch verbs are unchanged. Malformed
|
||||
configuration falls back to the inherited environment without evaluating its
|
||||
contents. Signed non-Steam prefix IDs are normalized to unsigned IDs.
|
||||
|
||||
Upstream references: [VKD3D capability parsing](https://github.com/HansKristian-Work/vkd3d-proton/blob/master/libs/vkd3d/device.c),
|
||||
[VKD3D options](https://github.com/HansKristian-Work/vkd3d-proton#environment-variables),
|
||||
[Proton runtime options](https://github.com/ValveSoftware/Proton/tree/proton_11.0#runtime-config-options),
|
||||
[Mesa variables](https://docs.mesa3d.org/envvars.html),
|
||||
[DXVK variables](https://github.com/doitsujin/dxvk#environment-variables),
|
||||
[DXVK frame-limiter changes](https://github.com/doitsujin/dxvk/releases).
|
||||
|
||||
Validation: `./gradlew testDebugUnitTest` and
|
||||
`python3 -m unittest discover -s tools/tests -p test_game_environment.py`.
|
||||
Actual game compatibility depends on the installed Proton, VKD3D and Vulkan driver;
|
||||
these checks do not establish that every device supports feature level 12_2 or SM 6_9.
|
||||
@@ -0,0 +1,62 @@
|
||||
# Steam game imports
|
||||
|
||||
Second Library still registers the selected Steam library. It now also scans Windows
|
||||
executables in its immediate game subfolders and `steamapps/common`, using the same
|
||||
importer as Added Games. Existing Steam manifests are preserved.
|
||||
|
||||
`bannerlator-steam-games watch` runs beside the client. It identifies a game by an
|
||||
existing manifest's install directory, a `steam_appid.txt` beside the game or selected
|
||||
executable, or a unique normalized title from Steam's store search with an explicit
|
||||
country. Matching also accepts the terminal Windows Edition and INTERGRADE suffixes;
|
||||
multiple matches still remain unresolved. Artwork's fuzzy matching is deliberately not
|
||||
used for identity. Ambiguous or unresolved titles remain non-Steam games.
|
||||
|
||||
Ownership comes from the running native ARM64 client's `BIsSubscribedApp`, through
|
||||
versioned SteamClient020 / SteamUser021 / STEAMAPPS_INTERFACE_VERSION008 interfaces. The
|
||||
helper gets the account from that connection, not from a public profile. No API key,
|
||||
password, profile visibility change, or CEF debugging port is needed. Each probe runs in
|
||||
a bounded subprocess so library failures cannot crash the importer or client.
|
||||
|
||||
The resulting snapshot is stored under that account's `userdata/<id>/config` and
|
||||
refreshed every 30 seconds while logged in. Title lookups retry every five minutes and
|
||||
when the source changes, so an early network failure does not end the watcher. Changed
|
||||
status and probe failures are written to the session log. Startup and post-exit import
|
||||
use the last successful snapshot for the selected account. A newly identified title
|
||||
therefore starts as a shortcut and becomes a Steam entry after Steam exits and starts
|
||||
again. An unavailable client or offline first launch falls back to shortcuts; existing
|
||||
snapshots remain usable offline. Steam still enforces the current license at launch.
|
||||
|
||||
For a confirmed game without a manifest, the importer links the source folder into the
|
||||
internal Steam library and writes an installed manifest with `Universe=1` and
|
||||
`StateFlags=4`. Steam ignores manifests without the universe field. This registers
|
||||
existing files; it does not assert a verified build ID or depot manifest. Steam can
|
||||
still require verification, updates or redistributables. Steam updates and uninstall
|
||||
operate on the linked game files, so imported folders must be writable. Existing Steam
|
||||
manifests and directories are preserved. Only the exact minimal manifests generated by
|
||||
the earlier importer are migrated, with the matching source symlink still present. If
|
||||
registration fails, the game remains a shortcut. Once Steam takes over an install,
|
||||
manage it in Steam; forgetting an Added Games folder only removes its shortcuts.
|
||||
|
||||
The account's `.droiddeck-routes.json` maps shortcut IDs to real Steam app IDs so the
|
||||
Android launcher uses the same title and Proton prefix as Steam. Other accounts do not
|
||||
inherit those routes. Managed shortcuts are replaced; user-created shortcuts and
|
||||
unreadable shortcut files are preserved. Both routes use the existing ARM64 Proton
|
||||
registration.
|
||||
|
||||
## Validation
|
||||
|
||||
Run `python3 -m unittest discover -s tools/tests -p 'test_*.py'` and Android unit tests.
|
||||
Tests cover identity and edition matching, network recovery, account isolation,
|
||||
owned/unowned/unknown routing, legacy manifest migration, manifest preservation,
|
||||
filesystem collisions, shortcut preservation, and the versioned IPC call/cleanup
|
||||
contract. On 2026-09-28, the ARM64 Steam client (1790545198) confirmed ownership and
|
||||
installed status through ISteamApps for Final Fantasy VII Rebirth (2909400), Final
|
||||
Fantasy VII Remake Intergrade (1462040), and Final Fantasy XV (637650), with all three
|
||||
install paths resolving to the original second-library folders. The old managed
|
||||
shortcuts were removed. This verifies library registration, not complete game launches.
|
||||
Unowned fallback and account isolation are covered by automated tests.
|
||||
|
||||
References: [Steam Apps
|
||||
API](https://partner.steamgames.com/doc/api/ISteamApps#BIsSubscribedApp), [Valve's
|
||||
versioned interfaces](https://github.com/ValveSoftware/Proton/tree/proton_11.0/lsteamcli
|
||||
ent/steamworks_sdk_154).
|
||||
@@ -309,6 +309,29 @@ grep -qi 'CN=Android, OU=Android, O=Android' <<<"${signature_output}" || {
|
||||
exit 1
|
||||
}
|
||||
|
||||
# With DroidDeck's own key at hand, sign as CI's main builds are (tools/release/sign-apk.sh), so
|
||||
# the apk installs over a release instead of being refused as a different signer. The key's
|
||||
# location and password live in an untracked .signing.env (RELEASE_KEYSTORE, RELEASE_STORE_PASSWORD,
|
||||
# RELEASE_KEY_ALIAS), looked for in this checkout and then in the main one, which worktrees share.
|
||||
signing_env=${DROIDDECK_SIGNING_ENV:-}
|
||||
if [[ -z "${signing_env}" ]]; then
|
||||
for candidate in "${repo_root}/.signing.env" \
|
||||
"$(dirname "$(git -C "${repo_root}" rev-parse --path-format=absolute --git-common-dir 2>/dev/null || echo .)")/.signing.env"; do
|
||||
[[ -f "${candidate}" ]] && { signing_env=${candidate}; break; }
|
||||
done
|
||||
fi
|
||||
if [[ -n "${signing_env}" ]]; then
|
||||
echo "Signing with DroidDeck's key (${signing_env})"
|
||||
(
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
. "${signing_env}"
|
||||
set +a
|
||||
BUILD_TOOLS="${build_tools}" "${repo_root}/tools/release/sign-apk.sh" "${apk}" standard "${apk}.release"
|
||||
)
|
||||
mv "${apk}.release" "${apk}"
|
||||
fi
|
||||
|
||||
printf 'APK: %s\n' "${apk}"
|
||||
printf 'SHA-256: '
|
||||
shasum -a 256 "${apk}" | awk '{print $1}'
|
||||
@@ -55,10 +55,10 @@ if [ -n "${BL_DEBUG_DIR:-}" ]; then
|
||||
echo "== droiddeck-desktop $(date -u '+%Y-%m-%d %H:%M:%S UTC') renderer=$WLR_RENDERER"
|
||||
fi
|
||||
export WLR_BACKENDS=wayland
|
||||
# The app's compositor draws no wl_pointer cursor surfaces, so a cursor wlroots hands to its
|
||||
# parent ("hardware" cursor on the wayland backend) never shows - on the GPU renderers the whole
|
||||
# cursor was missing. Draw it into the desktop's own frames instead.
|
||||
export WLR_NO_HARDWARE_CURSORS=${WLR_NO_HARDWARE_CURSORS:-1}
|
||||
# The cursor goes to the app's compositor as a wl_pointer cursor surface ("hardware" cursor on the
|
||||
# wayland backend), which the app draws as its pointer. Drawn into the desktop's own frames instead
|
||||
# (WLR_NO_HARDWARE_CURSORS=1), it cost every frame a full-screen composite: a software cursor
|
||||
# rules out direct scan-out, so a full-screen game was never handed to the app as it is.
|
||||
mkdir -p "$HOME/.config/labwc" "$HOME/.config/lxqt"
|
||||
# Our defaults for labwc and the panel, refreshed every session. What a user changes in the
|
||||
# panel's own settings lands in panel.conf and is kept; only rc.xml and autostart are ours.
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/python3
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def game_id(env):
|
||||
prefix = env.get("STEAM_COMPAT_DATA_PATH", "").rstrip("/")
|
||||
if not prefix:
|
||||
return None
|
||||
value = prefix.rsplit("/", 1)[-1]
|
||||
if not re.fullmatch(r"-?[0-9]+", value):
|
||||
return None
|
||||
number = int(value)
|
||||
if not -(2**31) <= number < 2**32 or number == 0:
|
||||
return None
|
||||
return str(number % 2**32)
|
||||
|
||||
|
||||
def apply_config(env, config, app_id):
|
||||
if config.get("version") != 1:
|
||||
raise ValueError("unsupported environment configuration")
|
||||
result = env.copy()
|
||||
entries = dict(config.get("shared", {}))
|
||||
entries.update(config.get("games", {}).get(app_id, {}))
|
||||
for name, value in entries.items():
|
||||
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name):
|
||||
raise ValueError("invalid environment name")
|
||||
if value is None:
|
||||
result.pop(name, None)
|
||||
elif isinstance(value, str) and "\0" not in value and len(value) <= 8192:
|
||||
result[name] = value
|
||||
else:
|
||||
raise ValueError("invalid environment value")
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
command = sys.argv[1:]
|
||||
if not command:
|
||||
return 2
|
||||
env = os.environ.copy()
|
||||
app_id = game_id(env)
|
||||
if len(command) > 1 and command[1] == "waitforexitandrun" and app_id:
|
||||
path = Path.home() / ".config/droiddeck/game-environment.json"
|
||||
try:
|
||||
with path.open(encoding="utf-8") as source:
|
||||
env = apply_config(env, json.load(source), app_id)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except (OSError, ValueError, TypeError, AttributeError):
|
||||
print("bannerlator-game-env: invalid configuration; using inherited environment", file=sys.stderr)
|
||||
os.execvpe(command[0], command, env)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -539,10 +539,31 @@ BUSCONF
|
||||
# again on the branch it just wrote.
|
||||
steam_channel=${BL_STEAM_CHANNEL:-publicbeta}
|
||||
steam_beta=$steam_root/package/beta
|
||||
if [ -n "${BL_ADDED_GAMES:-}" ] && [ -f "$BL_ADDED_GAMES" ]; then
|
||||
bannerlator-steam-games watch "$steam_root" "$BL_ADDED_GAMES" &
|
||||
games_watcher=$!
|
||||
fi
|
||||
echo "== steam args: ${_bl_ui[*]} -clientbeta $steam_channel -no-cef-sandbox -cef-force-gpu -cef-ozone-platform=x11 -cef-use-gl=angle -cef-use-angle=vulkan $*"
|
||||
# An update that cannot install asks for a restart (rc=42) every time; the count is capped so a
|
||||
# session can never again loop in silence, which reads as a menu opening and closing by itself.
|
||||
steam_updates=0
|
||||
rm -f "$HOME/.bl-stop-requested"
|
||||
if [ -n "${BL_LAUNCH_DIR:-}" ] && [ -d "$BL_LAUNCH_DIR" ]; then
|
||||
rm -f "$BL_LAUNCH_DIR/steam-stop"
|
||||
(
|
||||
while :; do
|
||||
if [ -e "$BL_LAUNCH_DIR/steam-stop" ]; then
|
||||
rm -f "$BL_LAUNCH_DIR/steam-stop"
|
||||
: > "$HOME/.bl-stop-requested"
|
||||
echo "== steam: the app asked for a clean exit"
|
||||
"$steam_root/steamrtarm64/steam" -shutdown >/dev/null 2>&1 || true
|
||||
exit 0
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
) &
|
||||
stop_watcher=$!
|
||||
fi
|
||||
while :; do
|
||||
rm -f "$HOME/.bl-restart-once"
|
||||
if [ -f "$steam_beta" ] && [ "$(cat "$steam_beta" 2>/dev/null)" != "$steam_channel" ]; then
|
||||
@@ -556,6 +577,13 @@ BUSCONF
|
||||
rc=$?
|
||||
echo "== steam exited rc=$rc"
|
||||
collect_steam_logs
|
||||
if [ -n "${BL_ADDED_GAMES:-}" ] && [ -f "$BL_ADDED_GAMES" ]; then
|
||||
bannerlator-steam-shortcuts "$steam_root" "$BL_ADDED_GAMES" || true
|
||||
fi
|
||||
if [ -f "$HOME/.bl-stop-requested" ]; then
|
||||
rm -f "$HOME/.bl-stop-requested" "$HOME/.bl-restart-once"
|
||||
break
|
||||
fi
|
||||
# The session asked for this exit (the ARM64 Proton landed): start again, without the URL
|
||||
# arguments, which were acted on. Not counted against the update cap.
|
||||
if [ -f "$HOME/.bl-restart-once" ]; then
|
||||
@@ -572,6 +600,14 @@ BUSCONF
|
||||
# The URL arguments were acted on the first time; a restart must not repeat them.
|
||||
set --
|
||||
done
|
||||
if [ -n "${stop_watcher:-}" ]; then
|
||||
kill "$stop_watcher" 2>/dev/null || true
|
||||
wait "$stop_watcher" 2>/dev/null || true
|
||||
fi
|
||||
if [ -n "${games_watcher:-}" ]; then
|
||||
kill "$games_watcher" 2>/dev/null || true
|
||||
wait "$games_watcher" 2>/dev/null || true
|
||||
fi
|
||||
if [ -n "${BL_DESKTOP:-}" ]; then
|
||||
# Started from the desktop (bannerlator-steam-launch): the desktop's own programs share this
|
||||
# script's parent, so reap_orphans - right under gamescope, where everything is the client's -
|
||||
|
||||
@@ -233,7 +233,7 @@ for name in %s; do
|
||||
bl_directaudio "$depot"
|
||||
bl_game_cpus
|
||||
bl_idtech3 "$@"
|
||||
exec ${BL_TASKSET:-} "$depot/proton" "$verb" "$@" "${BL_EXTRA_ARGS[@]}"
|
||||
exec ${BL_TASKSET:-} /usr/local/bin/bannerlator-game-env "$depot/proton" "$verb" "$@" "${BL_EXTRA_ARGS[@]}"
|
||||
fi
|
||||
done
|
||||
echo "bannerlator-proton: no ARM64 Proton depot yet - the client downloads one as this tool's dependency; retry the launch once it has" >&2
|
||||
@@ -282,7 +282,7 @@ bl_game_cpus
|
||||
bl_idtech3 "$@"
|
||||
# Proton takes its base directory from dirname(sys.argv[0]) without resolving it, so it has to be
|
||||
# started from its own tree.
|
||||
exec ${BL_TASKSET:-} "$here/%s" "$verb" "$@" "${BL_EXTRA_ARGS[@]}"
|
||||
exec ${BL_TASKSET:-} /usr/local/bin/bannerlator-game-env "$here/%s" "$verb" "$@" "${BL_EXTRA_ARGS[@]}"
|
||||
"""
|
||||
EXTRA_COMPAT_TOOL_VDF = """"compatibilitytools"
|
||||
{
|
||||
|
||||
+254
@@ -0,0 +1,254 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Resolve added games against the signed-in Steam client's licenses."""
|
||||
import ctypes as C
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import unicodedata
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
STATE = ".droiddeck-games.json"
|
||||
BASE_ID = 76561197960265728
|
||||
|
||||
|
||||
def read_json(path, default):
|
||||
try:
|
||||
return json.loads(Path(path).read_text())
|
||||
except (OSError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def save_json(path, value):
|
||||
path = Path(path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temp = path.with_suffix(".tmp")
|
||||
temp.write_text(json.dumps(value))
|
||||
os.replace(temp, path)
|
||||
|
||||
|
||||
def title_key(name):
|
||||
return "".join(c for c in unicodedata.normalize("NFKC", name.replace("™", "").replace("®", "")).casefold() if c.isalnum())
|
||||
|
||||
|
||||
def edition_key(name):
|
||||
name = re.sub(r"(?i)\s+(?:windows edition|intergrade)\s*$", "", name)
|
||||
return title_key(name)
|
||||
|
||||
|
||||
def source_stamp(game):
|
||||
try:
|
||||
exe = Path(game["exe"])
|
||||
stat = exe.stat()
|
||||
hints = []
|
||||
for parent in sorted({Path(game["folder"]), exe.parent}):
|
||||
hint = parent / "steam_appid.txt"
|
||||
hints.append(hint.read_text().strip() if hint.is_file() else "")
|
||||
return [game["name"], str(exe), stat.st_size, stat.st_mtime_ns, hints]
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def identify(game):
|
||||
folder = Path(game["folder"])
|
||||
steamapps = folder.parent.parent
|
||||
if folder.parent.name == "common" and steamapps.name == "steamapps":
|
||||
matches = []
|
||||
for manifest in steamapps.glob("appmanifest_*.acf"):
|
||||
try:
|
||||
text = manifest.read_text()
|
||||
install = re.search(r'"installdir"\s*"([^"\\]*)"', text, re.I)
|
||||
if install and install[1] == folder.name:
|
||||
matches.append(int(manifest.stem.removeprefix("appmanifest_")))
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
if matches:
|
||||
return matches[0] if len(matches) == 1 else None
|
||||
ids = set()
|
||||
for parent in {folder, Path(game["exe"]).parent}:
|
||||
try:
|
||||
appid = int((parent / "steam_appid.txt").read_text().strip())
|
||||
if 0 < appid < 0x80000000:
|
||||
ids.add(appid)
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
if ids:
|
||||
return next(iter(ids)) if len(ids) == 1 else None
|
||||
url = "https://store.steampowered.com/api/storesearch/?l=english&cc=US&term=" + urllib.parse.quote(game["name"])
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=5) as response:
|
||||
items = json.loads(response.read(1024 * 1024)).get("items", [])
|
||||
for key in (title_key, edition_key):
|
||||
ids = {int(item["id"]) for item in items
|
||||
if key(item.get("name", "")) == key(game["name"]) and key(game["name"])}
|
||||
if ids:
|
||||
return next(iter(ids)) if len(ids) == 1 else None
|
||||
return None
|
||||
except (OSError, ValueError, KeyError):
|
||||
return None
|
||||
|
||||
|
||||
def method(obj, slot, result, *args):
|
||||
if not obj:
|
||||
raise RuntimeError("Steam interface unavailable")
|
||||
table = C.cast(obj, C.POINTER(C.POINTER(C.c_void_p))).contents
|
||||
return C.CFUNCTYPE(result, C.c_void_p, *args)(table[slot])
|
||||
|
||||
|
||||
def probe(steam_root, ids):
|
||||
# Slots belong to Valve's SteamClient020, SteamUser021 and STEAMAPPS_INTERFACE_VERSION008.
|
||||
library = C.CDLL(str(Path(steam_root) / "steamrtarm64/steamclient.so"))
|
||||
library.CreateInterface.argtypes = [C.c_char_p, C.c_void_p]
|
||||
library.CreateInterface.restype = C.c_void_p
|
||||
client = library.CreateInterface(b"SteamClient020", None)
|
||||
pipe = method(client, 0, C.c_int)(client)
|
||||
if not pipe:
|
||||
raise RuntimeError("Steam pipe unavailable")
|
||||
user = 0
|
||||
try:
|
||||
user = method(client, 2, C.c_int, C.c_int)(client, pipe)
|
||||
if not user:
|
||||
raise RuntimeError("No signed-in Steam user")
|
||||
get_interface = lambda slot, version: method(client, slot, C.c_void_p, C.c_int, C.c_int, C.c_char_p)(client, user, pipe, version)
|
||||
account = get_interface(5, b"SteamUser021")
|
||||
if not method(account, 1, C.c_bool)(account):
|
||||
raise RuntimeError("Steam is offline")
|
||||
steamid = method(account, 2, C.c_uint64)(account)
|
||||
apps = get_interface(15, b"STEAMAPPS_INTERFACE_VERSION008")
|
||||
subscribed = method(apps, 6, C.c_bool, C.c_uint32)
|
||||
return {"account": str(steamid - BASE_ID), "checked": time.time(),
|
||||
"owned": {str(appid): bool(subscribed(apps, appid)) for appid in ids}}
|
||||
finally:
|
||||
if user:
|
||||
method(client, 4, None, C.c_int, C.c_int)(client, pipe, user)
|
||||
method(client, 1, C.c_bool, C.c_int)(client, pipe)
|
||||
|
||||
|
||||
def watch(steam_root, listing):
|
||||
games = read_json(listing, [])
|
||||
if os.environ.get("BL_STEAM_OFFLINE") == "1" or not games:
|
||||
return
|
||||
candidates, sources = {}, {}
|
||||
next_lookup = 0
|
||||
last_status = None
|
||||
while True:
|
||||
now = time.monotonic()
|
||||
for game in games:
|
||||
folder = game["folder"]
|
||||
stamp = source_stamp(game)
|
||||
if now >= next_lookup or stamp != sources.get(folder):
|
||||
sources[folder] = stamp
|
||||
appid = identify(game)
|
||||
if appid:
|
||||
candidates[folder] = appid
|
||||
else:
|
||||
candidates.pop(folder, None)
|
||||
if now >= next_lookup:
|
||||
next_lookup = now + 300
|
||||
status = "waiting for a matching Steam title"
|
||||
if candidates:
|
||||
try:
|
||||
result = subprocess.run([sys.executable, __file__, "probe", steam_root,
|
||||
*map(str, sorted(set(candidates.values())))],
|
||||
capture_output=True, text=True, timeout=15)
|
||||
if result.returncode != 0:
|
||||
status = "ownership probe failed: " + (result.stderr.strip()[-300:] or str(result.returncode))
|
||||
else:
|
||||
line = next(line for line in result.stdout.splitlines() if line.startswith("DROIDDECK_OWNERSHIP="))
|
||||
snapshot = json.loads(line.removeprefix("DROIDDECK_OWNERSHIP="))
|
||||
if not snapshot["account"].isdigit() or int(snapshot["account"]) <= 0:
|
||||
raise ValueError("invalid Steam account")
|
||||
snapshot["candidates"] = candidates
|
||||
snapshot["sources"] = sources
|
||||
save_json(Path(steam_root) / "userdata" / snapshot["account"] / "config" / STATE, snapshot)
|
||||
status = "matched %d/%d games; %d owned" % (len(candidates), len(games), sum(snapshot["owned"].values()))
|
||||
except (OSError, ValueError, KeyError, StopIteration, subprocess.TimeoutExpired) as error:
|
||||
status = "ownership check unavailable: " + str(error)
|
||||
if status != last_status:
|
||||
print("bannerlator-steam-games: " + status, flush=True)
|
||||
last_status = status
|
||||
time.sleep(30)
|
||||
|
||||
|
||||
def seed_install(steam_root, game, appid):
|
||||
steamapps = Path(steam_root) / "steamapps"
|
||||
common = steamapps / "common"
|
||||
name = f"DroidDeck-{appid}"
|
||||
target = common / name
|
||||
source = Path(game["folder"])
|
||||
title = game["name"].replace("\\", "\\\\").replace('"', '\\"').replace("\n", " ").replace("\r", " ")
|
||||
content = ('"AppState"\n{\n' + f' "appid" "{appid}"\n "Universe" "1"\n "name" "{title}"\n'
|
||||
+ f' "StateFlags" "4"\n "installdir" "{name}"\n' + '}\n')
|
||||
legacy = content.replace(' "Universe" "1"\n', '')
|
||||
manifest = steamapps / f"appmanifest_{appid}.acf"
|
||||
for root in [Path(steam_root), Path("/mnt/bannerlator-sd")]:
|
||||
existing = root / "steamapps" / manifest.name
|
||||
if existing.is_file():
|
||||
if (existing == manifest and source.is_dir() and target.is_symlink()
|
||||
and os.readlink(target) == str(source)
|
||||
and existing.read_text() in (legacy, legacy.replace('"StateFlags" "4"', '"StateFlags" "2"'))):
|
||||
temp = manifest.with_suffix(".droiddeck")
|
||||
temp.write_text(content)
|
||||
os.replace(temp, manifest)
|
||||
return True
|
||||
if not source.is_dir():
|
||||
return False
|
||||
common.mkdir(parents=True, exist_ok=True)
|
||||
if os.path.lexists(target):
|
||||
if not target.is_symlink() or os.readlink(target) != str(source):
|
||||
return False
|
||||
else:
|
||||
target.symlink_to(source, target_is_directory=True)
|
||||
try:
|
||||
with manifest.open("x") as output:
|
||||
output.write(content)
|
||||
except FileExistsError:
|
||||
pass
|
||||
return True
|
||||
|
||||
|
||||
def route(steam_root, acct, games):
|
||||
snapshot = read_json(Path(acct) / "config" / STATE, {})
|
||||
if not isinstance(snapshot, dict):
|
||||
snapshot = {}
|
||||
owned = snapshot.get("owned", {}) if snapshot.get("account") == Path(acct).name else {}
|
||||
candidates = snapshot.get("candidates", {})
|
||||
sources = snapshot.get("sources", {})
|
||||
if not all(isinstance(value, dict) for value in (owned, candidates, sources)):
|
||||
owned, candidates, sources = {}, {}, {}
|
||||
shortcuts, routes = [], {}
|
||||
for game in games:
|
||||
appid = candidates.get(game.get("folder"))
|
||||
stamp = source_stamp(game)
|
||||
confirmed = (isinstance(appid, int) and 0 < appid < 0x80000000
|
||||
and owned.get(str(appid)) is True and stamp is not None
|
||||
and sources.get(game.get("folder")) == stamp)
|
||||
if confirmed:
|
||||
try:
|
||||
if seed_install(steam_root, game, appid):
|
||||
routes[str(game["appid"])] = appid
|
||||
continue
|
||||
except OSError as error:
|
||||
print(f"bannerlator-steam-games: import unavailable ({error})")
|
||||
shortcuts.append(game)
|
||||
save_json(Path(acct) / "config/.droiddeck-routes.json", routes)
|
||||
return shortcuts, routes
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 4:
|
||||
sys.exit(2)
|
||||
try:
|
||||
if sys.argv[1] == "probe":
|
||||
print("DROIDDECK_OWNERSHIP=" + json.dumps(probe(sys.argv[2], [int(value) for value in sys.argv[3:]])), flush=True)
|
||||
elif sys.argv[1] == "watch":
|
||||
watch(sys.argv[2], sys.argv[3])
|
||||
else:
|
||||
sys.exit(2)
|
||||
except (OSError, RuntimeError, ValueError) as error:
|
||||
print(str(error), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -1,15 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Puts the app's added games (Windows games in the user's own folders) into the Steam client's
|
||||
library as non-Steam shortcuts, each mapped to the ARM64 Proton, before the client starts.
|
||||
"""Import added games while Steam is closed, preserving user-created shortcuts.
|
||||
|
||||
bannerlator-steam-shortcuts <steam_root> <added-games.json>
|
||||
|
||||
The JSON is a list the app writes each session: {"name", "exe", "dir", "appid"} with guest paths.
|
||||
The client keeps non-Steam games in userdata/<account>/config/shortcuts.vdf, a binary VDF it
|
||||
reads at start-up and rewrites at exit, so this runs while it is down. Entries the app wrote
|
||||
carry the tag "droiddeck-app" and are replaced wholesale; anything the user added inside the
|
||||
client is kept as it is. The appid is the one the client itself derives (crc32 of exe+name with
|
||||
the top bit set), so the launch URL the app uses matches what the client shows.
|
||||
Usage: bannerlator-steam-shortcuts <steam_root> <added-games.json>
|
||||
"""
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
@@ -207,8 +199,15 @@ def main():
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
existing = parse(f.read()).get("shortcuts", {})
|
||||
except (ValueError, IndexError) as e:
|
||||
print("bannerlator-steam-shortcuts: could not read the existing file (%s); starting over" % e)
|
||||
except (ValueError, IndexError, struct.error) as e:
|
||||
print("bannerlator-steam-shortcuts: could not read the existing file (%s); keeping it unchanged" % e)
|
||||
return 1
|
||||
helper = os.path.join(os.path.dirname(os.path.abspath(__file__)), "bannerlator-steam-games")
|
||||
loader = importlib.machinery.SourceFileLoader("steam_games", helper)
|
||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||
routing = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(routing)
|
||||
games, routes = routing.route(steam_root, acct, games)
|
||||
kept = [v for v in existing.values() if isinstance(v, dict) and TAG not in (v.get("tags") or {}).values()]
|
||||
ours = [shortcut(g) for g in games if g.get("exe") and g.get("name")]
|
||||
entries = {str(i): v for i, v in enumerate(kept + ours)}
|
||||
@@ -226,8 +225,8 @@ def main():
|
||||
compat = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(compat)
|
||||
cfg = os.path.join(steam_root, "config", "config.vdf")
|
||||
if os.path.isfile(cfg) and ours:
|
||||
compat.register_default(cfg, apps=[str(int(g["appid"]) & 0xFFFFFFFF) for g in games if g.get("exe")])
|
||||
if os.path.isfile(cfg) and (ours or routes):
|
||||
compat.register_default(cfg, apps=[str(int(g["appid"]) & 0xFFFFFFFF) for g in games if g.get("exe")] + [str(appid) for appid in set(routes.values())])
|
||||
print("bannerlator-steam-shortcuts: mapped them to %s" % compat.TOOL)
|
||||
except Exception as e: # noqa: BLE001 - the games still show; the client falls back to the default tool
|
||||
print("bannerlator-steam-shortcuts: compat mapping skipped (%s)" % e)
|
||||
|
||||
@@ -131,6 +131,9 @@ static struct pad *pad_for_syslink(const char *path) {
|
||||
|
||||
static void *next_symbol(const char *name) { return dlsym(RTLD_NEXT, name); }
|
||||
|
||||
char *bl_cached_realpath(const char *path, char *resolved, char *(*real)(const char *, char *))
|
||||
__attribute__((visibility("hidden")));
|
||||
|
||||
/* Wine reaches libudev from its HID bus driver, which is the only caller these pads are for. */
|
||||
static int caller_is_winebus(void *caller) {
|
||||
Dl_info info;
|
||||
@@ -152,7 +155,7 @@ char *realpath(const char *path, char *resolved) {
|
||||
}
|
||||
if (real_realpath == NULL) real_realpath = next_symbol("realpath");
|
||||
if (real_realpath == NULL) return NULL;
|
||||
return real_realpath(path, resolved);
|
||||
return bl_cached_realpath(path, resolved, real_realpath);
|
||||
}
|
||||
|
||||
void *udev_device_new_from_syspath(void *udev, const char *syspath) {
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
/*
|
||||
* Path lookups the Steam client repeats, answered from a short-lived cache.
|
||||
*
|
||||
* While it registers its compatibility tools after logon, the client resolves the same few
|
||||
* directories - its local tools under compatibilitytools.d and its own platform directory - with
|
||||
* realpath() and access() thousands of times a second. Each realpath() is one readlinkat() per
|
||||
* path component, and under proot every one of those is a trapped syscall with a round trip
|
||||
* through the tracer. Measured on an AYN Thor, that loop was 23 of the client's 31 seconds of
|
||||
* startup, with proot busy for 16 of them.
|
||||
*
|
||||
* Only the client itself uses the cache, never a game or a helper. Only successful answers are
|
||||
* kept, and only for a moment: a path that stops existing is noticed within PATH_CACHE_TTL_NS, and
|
||||
* one that starts existing is never hidden, since failures always go to the filesystem.
|
||||
* BL_NO_PATH_CACHE=1 turns it off.
|
||||
*/
|
||||
#define _GNU_SOURCE
|
||||
#include <dlfcn.h>
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <limits.h>
|
||||
#include <pthread.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#define PATH_CACHE_SLOTS 256
|
||||
#define PATH_CACHE_KEY_MAX 512
|
||||
#define PATH_CACHE_TTL_NS 2000000000LL
|
||||
|
||||
enum { KIND_REALPATH, KIND_ACCESS };
|
||||
|
||||
struct entry {
|
||||
long long stamp;
|
||||
unsigned hash;
|
||||
int kind;
|
||||
int mode;
|
||||
int flags;
|
||||
char key[PATH_CACHE_KEY_MAX];
|
||||
char *value;
|
||||
};
|
||||
|
||||
static struct entry table[PATH_CACHE_SLOTS];
|
||||
static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
|
||||
|
||||
static long long now_ns(void) {
|
||||
struct timespec t;
|
||||
|
||||
clock_gettime(CLOCK_MONOTONIC_COARSE, &t);
|
||||
return (long long)t.tv_sec * 1000000000LL + t.tv_nsec;
|
||||
}
|
||||
|
||||
static int enabled(void) {
|
||||
static int cached = -1;
|
||||
const char *off;
|
||||
|
||||
if (cached >= 0) return cached;
|
||||
off = getenv("BL_NO_PATH_CACHE");
|
||||
cached = strcmp(program_invocation_short_name, "steam") == 0 &&
|
||||
strstr(program_invocation_name, "steamrtarm64") != NULL &&
|
||||
!(off != NULL && off[0] == '1');
|
||||
return cached;
|
||||
}
|
||||
|
||||
static unsigned hash_of(const char *s, int kind, int mode, int flags) {
|
||||
unsigned h = 2166136261u ^ (unsigned)(kind * 31 + mode * 7 + flags);
|
||||
|
||||
while (*s) h = (h ^ (unsigned char)*s++) * 16777619u;
|
||||
return h;
|
||||
}
|
||||
|
||||
static int cacheable(const char *path) {
|
||||
return path != NULL && path[0] == '/' && strlen(path) < PATH_CACHE_KEY_MAX && enabled();
|
||||
}
|
||||
|
||||
static struct entry *find(const char *path, int kind, int mode, int flags, unsigned h) {
|
||||
struct entry *e = &table[h % PATH_CACHE_SLOTS];
|
||||
|
||||
if (e->stamp == 0 || e->hash != h || e->kind != kind || e->mode != mode || e->flags != flags) return NULL;
|
||||
if (now_ns() - e->stamp > PATH_CACHE_TTL_NS || strcmp(e->key, path) != 0) return NULL;
|
||||
return e;
|
||||
}
|
||||
|
||||
static void store(const char *path, int kind, int mode, int flags, unsigned h, const char *value) {
|
||||
struct entry *e = &table[h % PATH_CACHE_SLOTS];
|
||||
char *copy = value != NULL ? strdup(value) : NULL;
|
||||
|
||||
if (value != NULL && copy == NULL) return;
|
||||
free(e->value);
|
||||
e->value = copy;
|
||||
e->hash = h;
|
||||
e->kind = kind;
|
||||
e->mode = mode;
|
||||
e->flags = flags;
|
||||
memcpy(e->key, path, strlen(path) + 1);
|
||||
e->stamp = now_ns();
|
||||
}
|
||||
|
||||
__attribute__((visibility("hidden")))
|
||||
char *bl_cached_realpath(const char *path, char *resolved, char *(*real)(const char *, char *)) {
|
||||
char out[PATH_MAX];
|
||||
unsigned h;
|
||||
struct entry *e;
|
||||
|
||||
if (!cacheable(path)) return real(path, resolved);
|
||||
h = hash_of(path, KIND_REALPATH, 0, 0);
|
||||
pthread_mutex_lock(&lock);
|
||||
e = find(path, KIND_REALPATH, 0, 0, h);
|
||||
if (e != NULL && e->value != NULL) {
|
||||
size_t n = strlen(e->value) + 1;
|
||||
char *result = resolved != NULL ? resolved : malloc(n);
|
||||
if (result != NULL) memcpy(result, e->value, n);
|
||||
pthread_mutex_unlock(&lock);
|
||||
return result;
|
||||
}
|
||||
pthread_mutex_unlock(&lock);
|
||||
if (real(path, out) == NULL) return NULL;
|
||||
pthread_mutex_lock(&lock);
|
||||
store(path, KIND_REALPATH, 0, 0, h, out);
|
||||
pthread_mutex_unlock(&lock);
|
||||
if (resolved != NULL) {
|
||||
memcpy(resolved, out, strlen(out) + 1);
|
||||
return resolved;
|
||||
}
|
||||
return strdup(out);
|
||||
}
|
||||
|
||||
static int cached_access(const char *path, int mode, int flags, int (*check)(const char *, int, int)) {
|
||||
unsigned h;
|
||||
int hit;
|
||||
int result;
|
||||
|
||||
if (!cacheable(path)) return check(path, mode, flags);
|
||||
h = hash_of(path, KIND_ACCESS, mode, flags);
|
||||
pthread_mutex_lock(&lock);
|
||||
hit = find(path, KIND_ACCESS, mode, flags, h) != NULL;
|
||||
pthread_mutex_unlock(&lock);
|
||||
if (hit) return 0;
|
||||
result = check(path, mode, flags);
|
||||
if (result == 0) {
|
||||
pthread_mutex_lock(&lock);
|
||||
store(path, KIND_ACCESS, mode, flags, h, NULL);
|
||||
pthread_mutex_unlock(&lock);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static int (*next_faccessat(void))(int, const char *, int, int) {
|
||||
static int (*real)(int, const char *, int, int);
|
||||
|
||||
if (real == NULL) real = (int (*)(int, const char *, int, int))dlsym(RTLD_NEXT, "faccessat");
|
||||
return real;
|
||||
}
|
||||
|
||||
static int check_cwd(const char *path, int mode, int flags) {
|
||||
int (*real)(int, const char *, int, int) = next_faccessat();
|
||||
|
||||
if (real == NULL) {
|
||||
errno = ENOSYS;
|
||||
return -1;
|
||||
}
|
||||
return real(AT_FDCWD, path, mode, flags);
|
||||
}
|
||||
|
||||
int access(const char *path, int mode) {
|
||||
return cached_access(path, mode, 0, check_cwd);
|
||||
}
|
||||
|
||||
int euidaccess(const char *path, int mode) {
|
||||
return cached_access(path, mode, AT_EACCESS, check_cwd);
|
||||
}
|
||||
|
||||
int eaccess(const char *path, int mode) {
|
||||
return cached_access(path, mode, AT_EACCESS, check_cwd);
|
||||
}
|
||||
|
||||
int faccessat(int dirfd, const char *path, int mode, int flags) {
|
||||
int (*real)(int, const char *, int, int);
|
||||
|
||||
if (dirfd == AT_FDCWD) {
|
||||
return cached_access(path, mode, flags, check_cwd);
|
||||
}
|
||||
real = next_faccessat();
|
||||
if (real == NULL) {
|
||||
errno = ENOSYS;
|
||||
return -1;
|
||||
}
|
||||
return real(dirfd, path, mode, flags);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import runpy
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
BIN = Path(__file__).resolve().parents[1] / "linuxfs/overlay/usr/local/bin"
|
||||
MODULE = runpy.run_path(str(BIN / "bannerlator-game-env"))
|
||||
COMPAT = runpy.run_path(str(BIN / "bannerlator-steam-compat"))
|
||||
|
||||
|
||||
class GameEnvironmentTest(unittest.TestCase):
|
||||
def test_profile_precedence_and_unset(self):
|
||||
env = {"KEEP": "inherited", "REMOVE": "inherited", "CUSTOM": "launch option"}
|
||||
config = {"version": 1, "shared": {"CUSTOM": "shared"}, "games": {"42": {"REMOVE": None, "CUSTOM": "game", "EMPTY": ""}}}
|
||||
self.assertEqual(MODULE["apply_config"](env, config, "42"), {"KEEP": "inherited", "CUSTOM": "game", "EMPTY": ""})
|
||||
self.assertEqual(env["REMOVE"], "inherited")
|
||||
self.assertEqual(MODULE["apply_config"](env, config, "43")["CUSTOM"], "shared")
|
||||
|
||||
def test_invalid_configuration_is_atomic(self):
|
||||
env = {"ORIGINAL": "unchanged"}
|
||||
for entries in ({"A": "ok", "BAD=KEY": "x"}, {"A": "bad\0value"}, {"A": 1}):
|
||||
with self.assertRaises(ValueError):
|
||||
MODULE["apply_config"](env, {"version": 1, "shared": entries}, "42")
|
||||
self.assertEqual(env, {"ORIGINAL": "unchanged"})
|
||||
|
||||
def test_game_ids_and_probes(self):
|
||||
for prefix in ("", "/compatdata/0", "/compatdata/0-123", "/compatdata/nope", "/compatdata/4294967296"):
|
||||
self.assertIsNone(MODULE["game_id"]({"STEAM_COMPAT_DATA_PATH": prefix}))
|
||||
self.assertEqual(MODULE["game_id"]({"STEAM_COMPAT_DATA_PATH": "/a/compatdata/42/"}), "42")
|
||||
self.assertEqual(MODULE["game_id"]({"STEAM_COMPAT_DATA_PATH": "/a/compatdata/-1"}), "4294967295")
|
||||
|
||||
def test_launch_reads_updates_preserves_argv_and_does_not_execute_values(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
home = Path(tmp)
|
||||
config = home / ".config/droiddeck/game-environment.json"
|
||||
config.parent.mkdir(parents=True)
|
||||
probe = home / "fake-proton"
|
||||
probe.write_text("#!/usr/bin/python3\nimport json, os, sys\nprint(json.dumps([os.environ.get('CUSTOM'), sys.argv[1:]]))\n")
|
||||
probe.chmod(0o755)
|
||||
env = {**os.environ, "HOME": tmp, "STEAM_COMPAT_DATA_PATH": "/compatdata/42"}
|
||||
for value in ("first value", "$(touch " + str(home / "injected") + "); 'literal'=value"):
|
||||
config.write_text(json.dumps({"version": 1, "shared": {"CUSTOM": value}}))
|
||||
result = subprocess.check_output([sys.executable, str(BIN / "bannerlator-game-env"), str(probe), "waitforexitandrun", "path with spaces", "a=b"], env=env, text=True)
|
||||
self.assertEqual(json.loads(result), [value, ["waitforexitandrun", "path with spaces", "a=b"]])
|
||||
self.assertFalse((home / "injected").exists())
|
||||
for verb, prefix in (("run", "/compatdata/42"), ("waitforexitandrun", "/compatdata/0")):
|
||||
output = subprocess.check_output([sys.executable, str(BIN / "bannerlator-game-env"), str(probe), verb], env={**env, "STEAM_COMPAT_DATA_PATH": prefix, "CUSTOM": "original"}, text=True)
|
||||
self.assertEqual(json.loads(output)[0], "original")
|
||||
config.write_text("{broken")
|
||||
result = subprocess.run([sys.executable, str(BIN / "bannerlator-game-env"), str(probe), "waitforexitandrun"], env={**env, "CUSTOM": "original"}, text=True, capture_output=True, check=True)
|
||||
self.assertEqual(json.loads(result.stdout)[0], "original")
|
||||
|
||||
def test_generated_valve_and_third_party_launchers_apply_configuration(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
home = Path(tmp)
|
||||
steam = home / "Steam"
|
||||
tools = steam / "compatibilitytools.d"
|
||||
depot = steam / "steamapps/common" / COMPAT["SOURCES"][0]
|
||||
(depot / "files/bin-arm64").mkdir(parents=True)
|
||||
extra = tools / "custom-proton"
|
||||
extra.mkdir(parents=True)
|
||||
for base in (depot, extra):
|
||||
proton = base / "proton"
|
||||
proton.write_text("#!/usr/bin/python3\nimport json, os, sys\nprint(json.dumps([os.environ['CUSTOM'], sys.argv[1:]]))\nsys.exit(7)\n")
|
||||
proton.chmod(0o755)
|
||||
(extra / "toolmanifest.vdf").write_text('"manifest" { "commandline" "/proton %verb%" }')
|
||||
COMPAT["build_tool"](str(tools / COMPAT["TOOL"]))
|
||||
COMPAT["adopt_extras"](str(tools))
|
||||
config = home / ".config/droiddeck/game-environment.json"
|
||||
config.parent.mkdir(parents=True)
|
||||
config.write_text(json.dumps({"version": 1, "shared": {"CUSTOM": "shared"}, "games": {"42": {"CUSTOM": "specific"}}}))
|
||||
wrappers = (tools / COMPAT["TOOL"] / COMPAT["LAUNCHER"], extra / COMPAT["EXTRA_WRAPPER"])
|
||||
for wrapper in wrappers:
|
||||
wrapper.write_text(wrapper.read_text().replace("/usr/local/bin/bannerlator-game-env", str(BIN / "bannerlator-game-env")))
|
||||
result = subprocess.run([str(wrapper), "waitforexitandrun", "game with spaces.exe"],
|
||||
env={"PATH": os.defpath, "HOME": tmp, "STEAM_COMPAT_DATA_PATH": "/compatdata/42", "STEAM_COMPAT_CLIENT_INSTALL_PATH": str(steam)},
|
||||
text=True, capture_output=True)
|
||||
self.assertEqual(result.returncode, 7, result.stderr)
|
||||
self.assertEqual(json.loads(result.stdout), ["specific", ["waitforexitandrun", "game with spaces.exe"]])
|
||||
|
||||
def test_both_proton_wrappers_call_environment_launcher(self):
|
||||
for script in (COMPAT["LAUNCHER_SH"], COMPAT["EXTRA_WRAPPER_SH"] % "proton"):
|
||||
subprocess.run(["bash", "-n"], input=script, text=True, check=True)
|
||||
self.assertIn('exec ${BL_TASKSET:-} /usr/local/bin/bannerlator-game-env', script)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,231 @@
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
BIN = Path(__file__).resolve().parents[1] / 'linuxfs/overlay/usr/local/bin'
|
||||
|
||||
|
||||
def load(name):
|
||||
loader = importlib.machinery.SourceFileLoader(name, str(BIN / name))
|
||||
spec = importlib.util.spec_from_loader(name, loader)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
imports = load('bannerlator-steam-games')
|
||||
shortcuts = load('bannerlator-steam-shortcuts')
|
||||
|
||||
|
||||
class ImportsTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name)
|
||||
self.steam = self.root / 'Steam'
|
||||
self.acct = self.steam / 'userdata/123'
|
||||
self.folder = self.root / 'Games/Example'
|
||||
self.folder.mkdir(parents=True)
|
||||
self.exe = self.folder / 'Example.exe'
|
||||
self.exe.write_bytes(b'game')
|
||||
self.game = dict(name='Example', exe=str(self.exe), folder=str(self.folder),
|
||||
dir=str(self.folder), appid=0x87654321)
|
||||
|
||||
def snapshot(self, owned=True, account='123'):
|
||||
imports.save_json(self.acct / 'config' / imports.STATE,
|
||||
dict(account=account, owned={'42': owned},
|
||||
candidates={str(self.folder): 42},
|
||||
sources={str(self.folder): imports.source_stamp(self.game)}))
|
||||
|
||||
def test_owned_uses_real_id_and_registers_installed_files(self):
|
||||
self.snapshot()
|
||||
games, routes = imports.route(self.steam, self.acct, [self.game])
|
||||
self.assertEqual([], games)
|
||||
self.assertEqual({str(self.game['appid']): 42}, routes)
|
||||
manifest = self.steam / 'steamapps/appmanifest_42.acf'
|
||||
self.assertIn('"StateFlags" "4"', manifest.read_text())
|
||||
self.assertIn('"Universe" "1"', manifest.read_text())
|
||||
self.assertEqual(self.folder, (self.steam / 'steamapps/common/DroidDeck-42').resolve())
|
||||
self.assertEqual(b'game', self.exe.read_bytes())
|
||||
|
||||
def test_migrates_only_unchanged_legacy_import_manifests(self):
|
||||
self.snapshot()
|
||||
imports.route(self.steam, self.acct, [self.game])
|
||||
path = self.steam / 'steamapps/appmanifest_42.acf'
|
||||
current = path.read_text()
|
||||
legacy = current.replace(' "Universe" "1"\n', '').replace('"StateFlags" "4"', '"StateFlags" "2"')
|
||||
path.write_text(legacy)
|
||||
imports.route(self.steam, self.acct, [self.game])
|
||||
self.assertEqual(current, path.read_text())
|
||||
changed = legacy.replace('"StateFlags" "2"', '"StateFlags" "6"')
|
||||
path.write_text(changed)
|
||||
imports.route(self.steam, self.acct, [self.game])
|
||||
self.assertEqual(changed, path.read_text())
|
||||
|
||||
def test_unresolved_titles_are_retried_after_network_recovers(self):
|
||||
listing = self.root / 'games.json'
|
||||
listing.write_text(json.dumps([self.game]))
|
||||
from unittest.mock import Mock
|
||||
result = Mock(returncode=0, stdout='DROIDDECK_OWNERSHIP=' + json.dumps(dict(account='123', owned={'42': True})))
|
||||
with patch.object(imports, 'identify', side_effect=[None, 42]) as identify, \
|
||||
patch.object(imports.time, 'monotonic', side_effect=[0, 301]), \
|
||||
patch.object(imports.time, 'sleep', side_effect=[None, RuntimeError('stop')]), \
|
||||
patch.object(imports.subprocess, 'run', return_value=result), \
|
||||
patch('builtins.print'):
|
||||
with self.assertRaisesRegex(RuntimeError, 'stop'):
|
||||
imports.watch(self.steam, listing)
|
||||
self.assertEqual(2, identify.call_count)
|
||||
snapshot = imports.read_json(self.acct / 'config' / imports.STATE, {})
|
||||
self.assertTrue(snapshot['owned']['42'])
|
||||
|
||||
def test_unowned_unknown_and_different_account_stay_shortcuts(self):
|
||||
for owned, account in [(False, '123'), (True, '456')]:
|
||||
self.snapshot(owned, account)
|
||||
self.assertEqual(([self.game], {}), imports.route(self.steam, self.acct, [self.game]))
|
||||
(self.acct / 'config' / imports.STATE).unlink()
|
||||
self.assertEqual(([self.game], {}), imports.route(self.steam, self.acct, [self.game]))
|
||||
self.assertFalse((self.steam / 'steamapps').exists())
|
||||
|
||||
def test_existing_manifest_is_never_overwritten(self):
|
||||
manifest = self.steam / 'steamapps/appmanifest_42.acf'
|
||||
manifest.parent.mkdir(parents=True)
|
||||
manifest.write_text('Steam owns this file')
|
||||
self.snapshot()
|
||||
imports.route(self.steam, self.acct, [self.game])
|
||||
self.assertEqual('Steam owns this file', manifest.read_text())
|
||||
|
||||
def test_import_collision_falls_back_without_touching_files(self):
|
||||
occupied = self.steam / 'steamapps/common/DroidDeck-42'
|
||||
occupied.mkdir(parents=True)
|
||||
self.snapshot()
|
||||
self.assertEqual(([self.game], {}), imports.route(self.steam, self.acct, [self.game]))
|
||||
self.assertTrue(occupied.is_dir())
|
||||
self.assertFalse(occupied.is_symlink())
|
||||
|
||||
def test_removed_folder_does_not_import(self):
|
||||
self.snapshot()
|
||||
self.exe.unlink()
|
||||
self.folder.rmdir()
|
||||
self.assertEqual(([self.game], {}), imports.route(self.steam, self.acct, [self.game]))
|
||||
|
||||
def test_replaced_game_does_not_inherit_cached_ownership(self):
|
||||
self.snapshot()
|
||||
self.exe.write_bytes(b'a different game')
|
||||
self.assertEqual(([self.game], {}), imports.route(self.steam, self.acct, [self.game]))
|
||||
|
||||
def test_duplicate_owned_folders_share_one_steam_entry(self):
|
||||
self.snapshot()
|
||||
games, routes = imports.route(self.steam, self.acct, [self.game, self.game])
|
||||
self.assertEqual([], games)
|
||||
self.assertEqual(1, len(routes))
|
||||
self.assertEqual(1, len(list((self.steam / 'steamapps').glob('*.acf'))))
|
||||
|
||||
def test_explicit_appid_does_not_need_network(self):
|
||||
(self.folder / 'steam_appid.txt').write_text('42\n')
|
||||
with patch.object(imports.urllib.request, 'urlopen', side_effect=AssertionError):
|
||||
self.assertEqual(42, imports.identify(self.game))
|
||||
|
||||
def test_manifest_identity_uses_install_directory(self):
|
||||
folder = self.root / 'second/steamapps/common/Custom Name'
|
||||
folder.mkdir(parents=True)
|
||||
(folder.parent.parent / 'appmanifest_42.acf').write_text('"AppState" { "installdir" "Custom Name" }')
|
||||
game = dict(self.game, folder=str(folder), exe=str(folder / 'game.exe'))
|
||||
with patch.object(imports.urllib.request, 'urlopen', side_effect=AssertionError):
|
||||
self.assertEqual(42, imports.identify(game))
|
||||
|
||||
def test_store_requires_unique_exact_title(self):
|
||||
cases = [([{'id': 42, 'name': 'Example Deluxe'}], None),
|
||||
([{'id': 42, 'name': 'Example'}, {'id': 43, 'name': 'Example'}], None),
|
||||
([{'id': 42, 'name': 'EXAMPLE'}], 42)]
|
||||
for items, expected in cases:
|
||||
with patch.object(imports.urllib.request, 'urlopen') as request:
|
||||
request.return_value.__enter__.return_value.read.return_value = json.dumps({'items': items}).encode()
|
||||
self.assertEqual(expected, imports.identify(self.game))
|
||||
|
||||
def test_store_country_and_edition_suffixes(self):
|
||||
for folder, store in [('FINAL FANTASY VII REMAKE', 'FINAL FANTASY VII REMAKE INTERGRADE'),
|
||||
('FINAL FANTASY XV', 'FINAL FANTASY XV WINDOWS EDITION')]:
|
||||
with patch.object(imports.urllib.request, 'urlopen') as request:
|
||||
request.return_value.__enter__.return_value.read.return_value = json.dumps({'items': [{'id': 42, 'name': store}]}).encode()
|
||||
self.assertEqual(42, imports.identify(dict(self.game, name=folder)))
|
||||
self.assertIn('cc=US&', request.call_args.args[0])
|
||||
|
||||
def test_edition_matching_rejects_ambiguous_titles_and_soundtracks(self):
|
||||
with patch.object(imports.urllib.request, 'urlopen') as request:
|
||||
request.return_value.__enter__.return_value.read.return_value = json.dumps({'items': [
|
||||
{'id': 42, 'name': 'Example WINDOWS EDITION'},
|
||||
{'id': 43, 'name': 'Example INTERGRADE'},
|
||||
{'id': 44, 'name': 'Example Soundtrack'},
|
||||
]}).encode()
|
||||
self.assertIsNone(imports.identify(self.game))
|
||||
|
||||
def test_corrupt_shortcuts_are_preserved(self):
|
||||
path = self.acct / 'config/shortcuts.vdf'
|
||||
path.parent.mkdir(parents=True)
|
||||
original = b'corrupt'
|
||||
path.write_bytes(original)
|
||||
listing = self.root / 'games.json'
|
||||
listing.write_text(json.dumps([self.game]))
|
||||
run = subprocess.run(['python3', str(BIN / 'bannerlator-steam-shortcuts'), str(self.steam), str(listing)], capture_output=True)
|
||||
self.assertEqual(1, run.returncode)
|
||||
self.assertEqual(original, path.read_bytes())
|
||||
|
||||
def test_user_shortcuts_survive_owned_promotion(self):
|
||||
self.snapshot()
|
||||
path = self.acct / 'config/shortcuts.vdf'
|
||||
manual = dict(shortcuts.shortcut(self.game), tags={'0': 'Personal'}, AppName='Manual')
|
||||
path.write_bytes(shortcuts.write({'shortcuts': {'0': manual, '1': shortcuts.shortcut(self.game)}}))
|
||||
listing = self.root / 'games.json'
|
||||
listing.write_text(json.dumps([self.game]))
|
||||
subprocess.run(['python3', str(BIN / 'bannerlator-steam-shortcuts'), str(self.steam), str(listing)], check=True, capture_output=True)
|
||||
result = shortcuts.parse(path.read_bytes())['shortcuts']
|
||||
self.assertEqual([manual], list(result.values()))
|
||||
self.assertEqual({str(self.game['appid']): 42}, json.loads((path.parent / '.droiddeck-routes.json').read_text()))
|
||||
|
||||
|
||||
class SteamProbeTest(unittest.TestCase):
|
||||
def test_probe_reads_account_and_licenses_and_releases_handles(self):
|
||||
import ctypes as C
|
||||
from unittest.mock import Mock
|
||||
objects, callbacks, calls = [], [], []
|
||||
|
||||
def interface(slots):
|
||||
table = (C.c_void_p * 24)()
|
||||
for slot, result, args, callback in slots:
|
||||
fn = C.CFUNCTYPE(result, C.c_void_p, *args)(callback)
|
||||
callbacks.append(fn)
|
||||
table[slot] = C.cast(fn, C.c_void_p).value
|
||||
obj = C.pointer(C.cast(table, C.POINTER(C.c_void_p)))
|
||||
objects.extend([table, obj])
|
||||
return C.cast(obj, C.c_void_p).value
|
||||
|
||||
user = interface([
|
||||
(1, C.c_bool, [], lambda _: True),
|
||||
(2, C.c_uint64, [], lambda _: imports.BASE_ID + 123),
|
||||
])
|
||||
apps = interface([(6, C.c_bool, [C.c_uint32], lambda _, appid: appid == 42)])
|
||||
client = interface([
|
||||
(0, C.c_int, [], lambda _: 7),
|
||||
(1, C.c_bool, [C.c_int], lambda _, pipe: calls.append(('pipe', pipe)) or True),
|
||||
(2, C.c_int, [C.c_int], lambda _, pipe: 9),
|
||||
(4, None, [C.c_int, C.c_int], lambda _, pipe, handle: calls.append(('user', pipe, handle))),
|
||||
(5, C.c_void_p, [C.c_int, C.c_int, C.c_char_p], lambda _, handle, pipe, version: user if version == b'SteamUser021' else 0),
|
||||
(15, C.c_void_p, [C.c_int, C.c_int, C.c_char_p], lambda _, handle, pipe, version: apps if version == b'STEAMAPPS_INTERFACE_VERSION008' else 0),
|
||||
])
|
||||
library = Mock()
|
||||
library.CreateInterface.return_value = client
|
||||
with patch.object(imports.C, 'CDLL', return_value=library):
|
||||
result = imports.probe('/Steam', [42, 43])
|
||||
self.assertEqual('123', result['account'])
|
||||
self.assertEqual({'42': True, '43': False}, result['owned'])
|
||||
self.assertEqual([('user', 7, 9), ('pipe', 7)], calls)
|
||||
library.CreateInterface.assert_called_once_with(b'SteamClient020', None)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user