mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-14 19:27:09 -04:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d8be395e0 | ||
|
|
d4636afa97 | ||
|
|
32930e704e | ||
|
|
f877a8b168 | ||
|
|
a8b18947a1 | ||
|
|
7c422577b6 |
No files matched your search
@@ -466,6 +466,12 @@ static struct seat_pointer g_ptrs[MAX_PTRS];
|
||||
static int g_nptrs;
|
||||
static struct seat_keyboard g_kbs[MAX_PTRS];
|
||||
static int g_nkbs;
|
||||
/* Modifier state in the keymap's real-modifier bits (Shift=0x1, Lock=0x2, Control=0x4, Mod1=0x8,
|
||||
* Mod4=0x40). A client of the wayland backend - gamescope, labwc - takes modifiers only from
|
||||
* wl_keyboard.modifiers and ignores what a Shift key event does to its xkb state, so without this
|
||||
* every capital arrived lowercase. */
|
||||
static uint32_t g_mods_depressed, g_mods_locked;
|
||||
static uint32_t g_mod_keys_held; /* one bit per modifier key below, so a repeat press is harmless */
|
||||
static struct seat_touch g_touches[MAX_PTRS];
|
||||
static int g_ntouches;
|
||||
|
||||
@@ -2370,8 +2376,8 @@ static void keyboard_focus(struct wl_resource *target) {
|
||||
if (sk->focus == target) continue;
|
||||
sk->focus = target;
|
||||
wl_keyboard_send_enter(sk->kb, wl_display_next_serial(g_display), target, &keys);
|
||||
/* Baseline modifiers = none; Shift/Ctrl arrive as their own key events. */
|
||||
wl_keyboard_send_modifiers(sk->kb, wl_display_next_serial(g_display), 0, 0, 0, 0);
|
||||
wl_keyboard_send_modifiers(sk->kb, wl_display_next_serial(g_display),
|
||||
g_mods_depressed, 0, g_mods_locked, 0);
|
||||
}
|
||||
wl_array_release(&keys);
|
||||
banner_clipboard_keyboard_focus(client); /* wl_data_device selection follows focus */
|
||||
@@ -3068,7 +3074,34 @@ static void scroll_event(int steps) {
|
||||
wl_display_flush_clients(g_display);
|
||||
}
|
||||
|
||||
/* Folds a key into the modifier state; returns whether the state changed. */
|
||||
static int update_modifiers(uint32_t evdev, int pressed) {
|
||||
static const struct { uint32_t evdev, mask; } mod_keys[] = {
|
||||
{ 42, 0x1 }, { 54, 0x1 }, /* Shift L/R */
|
||||
{ 29, 0x4 }, { 97, 0x4 }, /* Control L/R */
|
||||
{ 56, 0x8 }, { 100, 0x8 }, /* Alt L/R, both Mod1 in this keymap */
|
||||
{ 125, 0x40 }, { 126, 0x40 }, /* Super L/R */
|
||||
};
|
||||
uint32_t depressed = g_mods_depressed, locked = g_mods_locked;
|
||||
if (evdev == 58) { /* Caps Lock toggles on press */
|
||||
if (pressed) locked ^= 0x2;
|
||||
} else {
|
||||
int i, n = (int)(sizeof(mod_keys) / sizeof(mod_keys[0]));
|
||||
for (i = 0; i < n && mod_keys[i].evdev != evdev; i++) {}
|
||||
if (i == n) return 0;
|
||||
if (pressed) g_mod_keys_held |= 1u << i; else g_mod_keys_held &= ~(1u << i);
|
||||
depressed = 0;
|
||||
for (int j = 0; j < n; j++) if (g_mod_keys_held & (1u << j)) depressed |= mod_keys[j].mask;
|
||||
}
|
||||
if (depressed == g_mods_depressed && locked == g_mods_locked) return 0;
|
||||
g_mods_depressed = depressed;
|
||||
g_mods_locked = locked;
|
||||
return 1;
|
||||
}
|
||||
|
||||
static void key_event(uint32_t evdev, int pressed) {
|
||||
/* Tracked even with nothing to deliver to, so a Shift released unseen is not left held. */
|
||||
int mods_changed = update_modifiers(evdev, pressed);
|
||||
/* Keys go to the program window the user last clicked (else the topmost non-shell window),
|
||||
* never to Wine's desktop surface: winewayland hands each key to the hwnd of the surface that
|
||||
* holds keyboard focus, and a key handed to explorer's desktop hwnd is queued to explorer's
|
||||
@@ -3088,9 +3121,13 @@ static void key_event(uint32_t evdev, int pressed) {
|
||||
struct seat_keyboard *sk;
|
||||
if (!keyboard_for(client)) return;
|
||||
keyboard_focus(target->resource);
|
||||
for_each_keyboard_of(client, sk)
|
||||
for_each_keyboard_of(client, sk) {
|
||||
wl_keyboard_send_key(sk->kb, wl_display_next_serial(g_display), now_ms(), evdev,
|
||||
pressed ? WL_KEYBOARD_KEY_STATE_PRESSED : WL_KEYBOARD_KEY_STATE_RELEASED);
|
||||
if (mods_changed)
|
||||
wl_keyboard_send_modifiers(sk->kb, wl_display_next_serial(g_display),
|
||||
g_mods_depressed, 0, g_mods_locked, 0);
|
||||
}
|
||||
wl_display_flush_clients(g_display);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -26,9 +26,24 @@ public final class PadBridge {
|
||||
/** Slot 0 is the one the session exports; extra slots would each show as another pad. */
|
||||
private static final int SLOT = 0;
|
||||
private static final float DEAD_ZONE = 0.12f;
|
||||
private static final long QAM_GUIDE_LEAD_MS = 80;
|
||||
private static final long QAM_A_HOLD_MS = 120;
|
||||
private static final long QAM_GUIDE_TAIL_MS = 40;
|
||||
// The client takes A as part of the chord only once it has had Guide held for a while, and a
|
||||
// client starved of CPU needs longer. Too short, and it acts on A as well, selecting whatever it
|
||||
// had focused before opening QAM: with 80 ms of lead that was 3 times in 20 at rest, and 250 ms
|
||||
// none in 20; with the session down to 1 fps a fixed 400 ms still let 3 in 15 through, where
|
||||
// 1000 ms let none. The lead keeps 80 ms when frames are quick, for a QAM that feels immediate,
|
||||
// and stretches to eight frames when they are slow.
|
||||
private static final long QAM_GUIDE_LEAD_MIN_MS = 80;
|
||||
private static final long QAM_GUIDE_LEAD_MAX_MS = 1500;
|
||||
private static final int QAM_GUIDE_LEAD_FRAMES = 8;
|
||||
private static final long QAM_A_HOLD_MS = 200;
|
||||
private static final long QAM_GUIDE_TAIL_MS = 200;
|
||||
// Off the main thread, so a busy UI cannot shorten or stretch the steps.
|
||||
private static final Handler chordHandler;
|
||||
static {
|
||||
android.os.HandlerThread thread = new android.os.HandlerThread("qam-chord", android.os.Process.THREAD_PRIORITY_DISPLAY);
|
||||
thread.start();
|
||||
chordHandler = new Handler(thread.getLooper());
|
||||
}
|
||||
|
||||
private final FakeInputWriter writer;
|
||||
private final PadState state = new PadState();
|
||||
@@ -212,21 +227,27 @@ public final class PadBridge {
|
||||
qamChordActive = true;
|
||||
int generation = ++qamChordGeneration;
|
||||
publish();
|
||||
mainHandler.postDelayed(() -> pressQamA(generation), QAM_GUIDE_LEAD_MS);
|
||||
chordHandler.postDelayed(() -> pressQamA(generation), qamGuideLeadMs());
|
||||
}
|
||||
|
||||
static long qamGuideLeadMs() {
|
||||
long frame = com.droiddeck.launcher.wayland.WaylandCompositor.recentFrameIntervalMs();
|
||||
if (frame <= 0) return QAM_GUIDE_LEAD_MIN_MS;
|
||||
return Math.max(QAM_GUIDE_LEAD_MIN_MS, Math.min(QAM_GUIDE_LEAD_MAX_MS, frame * QAM_GUIDE_LEAD_FRAMES));
|
||||
}
|
||||
|
||||
private synchronized void pressQamA(int generation) {
|
||||
if (generation != qamChordGeneration || !qamChordActive) return;
|
||||
qamSyntheticAPressed = true;
|
||||
publish();
|
||||
mainHandler.postDelayed(() -> releaseQamA(generation), QAM_A_HOLD_MS);
|
||||
chordHandler.postDelayed(() -> releaseQamA(generation), QAM_A_HOLD_MS);
|
||||
}
|
||||
|
||||
private synchronized void releaseQamA(int generation) {
|
||||
if (generation != qamChordGeneration || !qamChordActive) return;
|
||||
qamSyntheticAPressed = false;
|
||||
publish();
|
||||
mainHandler.postDelayed(() -> releaseQamGuide(generation), QAM_GUIDE_TAIL_MS);
|
||||
chordHandler.postDelayed(() -> releaseQamGuide(generation), QAM_GUIDE_TAIL_MS);
|
||||
}
|
||||
|
||||
private synchronized void releaseQamGuide(int generation) {
|
||||
|
||||
@@ -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") }
|
||||
},
|
||||
|
||||
@@ -67,9 +67,30 @@ public final class WaylandCompositor {
|
||||
if (l != null) l.onGameSurface(window, gpuName);
|
||||
}
|
||||
|
||||
private static volatile long lastFrameNanos;
|
||||
private static volatile long frameIntervalNanos;
|
||||
|
||||
/**
|
||||
* How long frames are taking to arrive right now, in ms, or -1 before the first: the smoothed
|
||||
* interval, or the time since the last frame when that is longer (a session that has stalled).
|
||||
*/
|
||||
public static long recentFrameIntervalMs() {
|
||||
long last = lastFrameNanos;
|
||||
if (last == 0) return -1;
|
||||
return Math.max(frameIntervalNanos, System.nanoTime() - last) / 1_000_000;
|
||||
}
|
||||
|
||||
/** Invoked from native (banner_on_game_frame) for every frame of the HUD's window. */
|
||||
@SuppressWarnings("unused")
|
||||
static void onGameFrame() {
|
||||
long now = System.nanoTime();
|
||||
long previous = lastFrameNanos;
|
||||
lastFrameNanos = now;
|
||||
if (previous != 0) {
|
||||
long interval = now - previous;
|
||||
long smoothed = frameIntervalNanos;
|
||||
frameIntervalNanos = smoothed == 0 ? interval : (smoothed * 3 + interval) / 4;
|
||||
}
|
||||
com.droiddeck.launcher.session.PerfHints.onFrame();
|
||||
GameListener l = gameListener;
|
||||
if (l != null) l.onGameFrame();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user