Compare commits

...
8 changed files with 436 additions and 29 deletions

No files matched your search

@@ -89,6 +89,9 @@ class MainActivity : ComponentActivity() {
private var flood by mutableStateOf<PendingFlood?>(null)
private var floodProgress by androidx.compose.runtime.mutableFloatStateOf(0f)
private class PendingFlood(val from: androidx.compose.ui.geometry.Rect, val intent: Intent)
/** A session stopped behind a flood: its blue, drawn back into the button it started from (FloodReturn). */
private var returning by mutableStateOf<ReturningFlood?>(null)
private class ReturningFlood(val color: Int, val to: androidx.compose.ui.geometry.Rect?)
private var ready by mutableStateOf(false)
private var available by mutableStateOf<LinuxRuntimeInstaller.Release?>(null)
private var busy by mutableStateOf(false)
@@ -481,6 +484,13 @@ class MainActivity : ComponentActivity() {
)
if (showCredits) CreditsDialog { showCredits = false }
flood?.let { f -> com.droiddeck.launcher.ui.LaunchFlood(f.from, onProgress = { floodProgress = it }) { launchFlooded(f) } }
returning?.let { r ->
com.droiddeck.launcher.ui.FloodReturn(androidx.compose.ui.graphics.Color(r.color), r.to, onProgress = { floodProgress = it }) {
returning = null
floodProgress = 0f
com.droiddeck.launcher.ui.LaunchOrigin.flooding = null
}
}
}
}
@@ -557,6 +567,10 @@ class MainActivity : ComponentActivity() {
override fun onResume() {
super.onResume()
com.droiddeck.launcher.ui.Motion.refresh(this)
// Back from a session stopped behind a flood: open on its blue, before the first frame.
com.droiddeck.launcher.ui.QuitFlood.take()?.let { c ->
returning = ReturningFlood(c, com.droiddeck.launcher.ui.LaunchOrigin.takeReturn())
}
refreshPhantomStatus()
// Swaps queued while a game ran on that Proton go in once nothing uses it (usually the
// session has just ended). Cheap when nothing is queued.
@@ -592,6 +606,7 @@ class MainActivity : ComponentActivity() {
displayManager.unregisterDisplayListener(secondScreenDisplayListener)
// The session covers the page by now; coming back finds it as it was.
flood = null
returning = null
floodProgress = 0f
com.droiddeck.launcher.ui.LaunchOrigin.flooding = null
super.onStop()
@@ -29,6 +29,8 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.unit.dp
import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.frontend.Library
import com.droiddeck.launcher.gpu.FrameGen
@@ -144,6 +146,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
// Compose reads these; the activity writes them.
private var drawerOpen by mutableStateOf(false)
/** A stop growing out of the dialog's Stop (overlay px); the session ends once it covers the screen. */
private var quitFrom by mutableStateOf<androidx.compose.ui.geometry.Rect?>(null)
/** Left behind that flood: the front end opens on its blue, so no sink of our own. */
private var quitFlooded = false
/** The drawer's Components tab: the Protons as last read (ComponentsManager). */
private var drawerComponents by mutableStateOf<ComponentsManager.Snapshot?>(null)
private var drawerPage by mutableIntStateOf(0)
@@ -394,6 +400,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
onBackground = { drawerOpen = false; moveTaskToBack(true) },
onShareLogs = { drawerOpen = false; shareCurrentSessionLogs() },
onStop = { drawerOpen = false; SessionService.stop(this@SessionActivity); finish() },
onStopFrom = { onScreen ->
drawerOpen = false
if (onScreen == null || com.droiddeck.launcher.ui.Motion.scale == 0f || quitFrom != null) {
if (quitFrom == null) { SessionService.stop(this@SessionActivity); finish() }
} else {
val at = IntArray(2).also { sessionOverlay.getLocationOnScreen(it) }
quitFrom = onScreen.translate(-at[0].toFloat(), -at[1].toFloat())
}
},
onClose = { drawerOpen = false },
components = drawerComponents,
onComponentsRefresh = { refreshDrawerComponents() },
@@ -403,6 +418,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
title = pausedTitle(),
onResume = { SessionService.resume(this@SessionActivity) },
)
quitFrom?.let { from ->
val error = androidx.compose.material3.MaterialTheme.colorScheme.error
com.droiddeck.launcher.ui.LaunchFlood(
from, onProgress = {},
fromColors = error to androidx.compose.ui.graphics.lerp(error, androidx.compose.ui.graphics.Color.Black, 0.12f),
cornerAtRest = 20.dp,
) { finishFlooded() }
}
}
}
}
@@ -1554,10 +1577,22 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
super.onDestroy()
}
/** The screen is all blue: the session stops and the front end opens on the same blue (FloodReturn). */
private fun finishFlooded() {
if (isFinishing) return
val signal = com.droiddeck.launcher.ui.Themes.byId(SessionPrefs.theme(this)).signal
com.droiddeck.launcher.ui.QuitFlood.mark(signal.toArgb())
window.setBackgroundDrawable(android.graphics.drawable.ColorDrawable(signal.toArgb()))
quitFlooded = true
SessionService.stop(this)
finish()
}
/** Leaving the session sinks its surface back down onto the front end. */
override fun finish() {
super.finish()
overridePendingTransition(R.anim.session_hold, R.anim.session_sink)
if (quitFlooded) overridePendingTransition(0, 0)
else overridePendingTransition(R.anim.session_hold, R.anim.session_sink)
}
companion object {
@@ -736,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) {
@@ -953,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()
@@ -1114,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"
@@ -36,9 +36,18 @@ import kotlinx.coroutines.coroutineScope
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.runtime.snapshotFlow
import androidx.compose.animation.core.spring
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.unit.Dp
import java.lang.ref.WeakReference
import kotlin.math.PI
import kotlin.math.sin
// Starting a session from a blue button: the button grows until the page is its blue, and the
// loading screen opens on that blue and gathers it into the ball of the throbber.
// Stopping one from the drawer runs it the other way: Stop grows until the page is blue, and the
// front end opens on that blue and draws it back down into the button the session came from.
/** Carries the flood's colour to the session, which opens on it. */
const val EXTRA_FLOOD = "com.droiddeck.launcher.FLOOD"
@@ -48,6 +57,8 @@ internal object LaunchOrigin {
private var bounds: Rect? = null
private var button: Any? = null
private var at = 0L
/** The last button a session was started from, for a stop to return into. */
private var last: WeakReference<Any>? = null
/** The button a flood is growing out of: it steps aside so only the flood shows. */
var flooding by mutableStateOf<Any?>(null)
@@ -55,9 +66,22 @@ internal object LaunchOrigin {
fun mark(r: Rect, key: Any) {
bounds = r
button = key
last = WeakReference(key)
at = SystemClock.uptimeMillis()
}
/**
* Where the button the running session was started from sits now, if it is still on screen;
* it hides for the return, as for the flood.
*/
fun takeReturn(): Rect? {
val key = last?.get() as? Array<*> ?: return null
val c = key.getOrNull(0) as? LayoutCoordinates ?: return null
if (!c.isAttached) return null
flooding = key
return c.boundsInRoot()
}
/** The button behind a launch starting now, if one was pressed just before; it hides for the flood. */
fun take(): Rect? {
val b = bounds?.takeIf { SystemClock.uptimeMillis() - at < 1_500 }
@@ -68,6 +92,23 @@ internal object LaunchOrigin {
}
}
/** A session stopped behind a flood: the blue the front end opens on, for a few seconds. */
internal object QuitFlood {
private var color: Int? = null
private var at = 0L
fun mark(argb: Int) {
color = argb
at = SystemClock.uptimeMillis()
}
fun take(): Int? {
val c = color?.takeIf { SystemClock.uptimeMillis() - at < 3_000 }
color = null
return c
}
}
/** The page a flood grows over, sinking back a touch as it is covered; [progress] is the flood's. */
@Composable
internal fun FloodBehind(progress: () -> Float, content: @Composable () -> Unit) {
@@ -87,9 +128,17 @@ private val Gather = CubicBezierEasing(0.6f, 0f, 0.15f, 1f)
* Swallows touches while it runs.
*/
@Composable
internal fun LaunchFlood(from: Rect, onProgress: (Float) -> Unit, onCovered: () -> Unit) {
internal fun LaunchFlood(
from: Rect,
onProgress: (Float) -> Unit,
/** The button's own colours, where it is not the page's blue one (a red Stop). */
fromColors: Pair<Color, Color>? = null,
cornerAtRest: Dp = 12.dp,
onCovered: () -> Unit,
) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val start = fromColors ?: (colors.primary to pal.primary2)
// Left, top, right, bottom: 0 on the button, 1 on the page's edge. Springs overshoot past the
// edge, off the page, so the wobble is felt in the pull and never seen as a shrink.
val edges = remember { List(4) { Animatable(0f) } }
@@ -117,30 +166,83 @@ internal fun LaunchFlood(from: Rect, onProgress: (Float) -> Unit, onCovered: ()
}
}
Canvas(Modifier.fillMaxSize().onSizeChanged { page = Size(it.width.toFloat(), it.height.toFloat()) }.pointerInput(Unit) { awaitEachGesture { while (true) awaitPointerEvent().changes.forEach { it.consume() } } }) {
val (l, t, r, b) = edges.map { it.value }
val mean = edges.sumOf { it.value.coerceIn(0f, 1f).toDouble() }.toFloat() / 4f
// The button sinks with the page behind (FloodBehind), so the flood leaves from where it is now.
val k = sinkScale(mean)
val cx = size.width / 2f
val cy = size.height / 2f
val left = lerp(cx + (from.left - cx) * k, 0f, l)
val top = lerp(cy + (from.top - cy) * k, 0f, t)
val right = lerp(cx + (from.right - cx) * k, size.width, r)
val bottom = lerp(cy + (from.bottom - cy) * k, size.height, b)
val w = (right - left).coerceAtLeast(0f)
val h = (bottom - top).coerceAtLeast(0f)
// A button's corners at rest, a blob in flight, square once it fills the page.
val blob = kotlin.math.sin(Math.PI * mean).toFloat().coerceAtLeast(0f)
val corner = lerp(12.dp.toPx() * (1f - mean), minOf(w, h) * 0.42f, blob)
// The button's gradient gives way to the ball's flat blue in the first half.
val mix = (mean * 2f).coerceAtMost(1f)
drawRoundRect(
Brush.linearGradient(
listOf(lerp(colors.primary, pal.signal, mix), lerp(pal.primary2, pal.signal, mix)),
start = Offset(left, top), end = Offset(right, bottom),
),
topLeft = Offset(left, top), size = Size(w, h), cornerRadius = CornerRadius(corner),
)
drawFlood(from, edges.map { it.value }, start, pal.signal, cornerAtRest.toPx())
}
}
/**
* The flood from a button at [from] with its edges [v] of the way to the page's (left, top, right,
* bottom): the button sinking with the page behind, blobby in flight, its colours [start] giving
* way to the flat [signal] in the first half.
*/
private fun DrawScope.drawFlood(from: Rect, v: List<Float>, start: Pair<Color, Color>, signal: Color, cornerAtRest: Float) {
val (l, t, r, b) = v
val mean = v.sumOf { it.coerceIn(0f, 1f).toDouble() }.toFloat() / 4f
// The button sinks with the page behind (FloodBehind), so the flood leaves from where it is now.
val k = sinkScale(mean)
val cx = size.width / 2f
val cy = size.height / 2f
val left = lerp(cx + (from.left - cx) * k, 0f, l)
val top = lerp(cy + (from.top - cy) * k, 0f, t)
val right = lerp(cx + (from.right - cx) * k, size.width, r)
val bottom = lerp(cy + (from.bottom - cy) * k, size.height, b)
val w = (right - left).coerceAtLeast(0f)
val h = (bottom - top).coerceAtLeast(0f)
// A button's corners at rest, a blob in flight, square once it fills the page.
val blob = sin(PI * mean).toFloat().coerceAtLeast(0f)
val corner = lerp(cornerAtRest * (1f - mean), minOf(w, h) * 0.42f, blob)
// The button's gradient gives way to the ball's flat blue in the first half.
val mix = (mean * 2f).coerceAtMost(1f)
val brush = Brush.linearGradient(
listOf(lerp(start.first, signal, mix), lerp(start.second, signal, mix)),
start = Offset(left, top), end = Offset(right, bottom),
)
drawRoundRect(brush, topLeft = Offset(left, top), size = Size(w, h), cornerRadius = CornerRadius(corner))
}
/**
* The front end's first frames after a stop: the page in [flood] after a beat, drawn back down
* into the button at [to] (root px, measured before the page sank) on critically damped springs,
* the far edges first, so it sets down on the button without a bounce. [onProgress] (1 to 0) lets
* the page behind rise back. No button to return to: the blue fades. Calls [onLanded] at the end.
*/
@Composable
internal fun FloodReturn(flood: Color, to: Rect?, onProgress: (Float) -> Unit, onLanded: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val edges = remember { List(4) { Animatable(1f) } }
val fade = remember { Animatable(1f) }
val landed by rememberUpdatedState(onLanded)
val progress by rememberUpdatedState(onProgress)
var page by remember { mutableStateOf(Size.Zero) }
LaunchedEffect(page != Size.Zero) {
if (page == Size.Zero) return@LaunchedEffect
progress(1f)
// A beat of plain blue first, so the change of screen does not read as a cut.
delay(Motion.ms(220).toLong())
if (to == null || Motion.scale == 0f) {
fade.animateTo(0f, tween(Motion.ms(240).coerceAtLeast(1)))
} else {
val travel = listOf(to.left, to.top, page.width - to.right, page.height - to.bottom).map { it.coerceAtLeast(0f) }
val far = travel.max().coerceAtLeast(1f)
coroutineScope {
edges.mapIndexed { i, edge ->
val lead = travel[i] / far
launch {
delay(Motion.ms((180 * (1f - lead)).toInt()).toLong())
edge.animateTo(0f, spring(dampingRatio = 1f, stiffness = lerp(110f, 190f, lead))) {
progress(edges.sumOf { it.value.coerceIn(0f, 1f).toDouble() }.toFloat() / 4f)
}
}
}.forEach { it.join() }
}
}
progress(0f)
landed()
}
Canvas(Modifier.fillMaxSize().onSizeChanged { page = Size(it.width.toFloat(), it.height.toFloat()) }.pointerInput(Unit) { awaitEachGesture { while (true) awaitPointerEvent().changes.forEach { it.consume() } } }) {
if (to == null) drawRect(flood, alpha = fade.value)
else drawFlood(to, edges.map { it.value }, colors.primary to pal.primary2, flood, 12.dp.toPx())
}
}
@@ -85,6 +85,9 @@ import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.InputMode
import androidx.compose.ui.platform.LocalInputModeManager
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
@@ -172,6 +175,8 @@ class DrawerActions(
val onBackground: () -> Unit,
val onShareLogs: () -> Unit,
val onStop: () -> Unit,
/** Stop with the dialog's button's bounds on screen, for the flood to grow out of; null falls back to [onStop]. */
val onStopFrom: ((androidx.compose.ui.geometry.Rect?) -> Unit)? = null,
val onClose: () -> Unit,
/** Components tab: every Proton with what it uses; null until first read. */
val components: ComponentsManager.Snapshot? = null,
@@ -453,7 +458,14 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
if (confirmStop) {
val cancelFocus = remember { FocusRequester() }
val cancel = { confirmStop = false }
val stop = { confirmStop = false; a.onStop() }
// Where Stop sits on screen: the dialog is a window of its own, so its bounds are taken
// against the screen and the session's overlay converts them back.
var stopOnScreen by remember { mutableStateOf<androidx.compose.ui.geometry.Rect?>(null) }
val stop = {
confirmStop = false
val from = a.onStopFrom
if (from != null) from(stopOnScreen) else a.onStop()
}
LaunchedEffect(controllerActive) {
if (controllerActive) {
androidx.compose.runtime.withFrameNanos { }
@@ -465,9 +477,13 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
modifier = Modifier.controllerBack(onBack = cancel),
title = { Text("Stop session?") },
confirmButton = {
val dialogView = LocalView.current
TextButton(
onClick = stop,
modifier = Modifier.controllerConfirm(onClick = stop),
modifier = Modifier.controllerConfirm(onClick = stop).onGloballyPositioned { c ->
val at = IntArray(2).also { dialogView.rootView.getLocationOnScreen(it) }
stopOnScreen = c.boundsInWindow().translate(at[0].toFloat(), at[1].toFloat())
},
colors = ButtonDefaults.textButtonColors(contentColor = colors.error),
) { Text("Stop") }
},
@@ -547,6 +547,23 @@ BUSCONF
# 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
@@ -563,6 +580,10 @@ BUSCONF
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
@@ -579,6 +600,10 @@ 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
+4 -1
View File
@@ -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) {
+189
View File
@@ -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);
}