mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-14 19:27:09 -04:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36f0b02573 | ||
|
|
24c250fdb4 | ||
|
|
d4636afa97 | ||
|
|
32930e704e | ||
|
|
a8b18947a1 | ||
|
|
7c422577b6 | ||
|
|
1cc1ee994d |
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -88,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)
|
||||
@@ -431,6 +435,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() },
|
||||
@@ -474,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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -550,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.
|
||||
@@ -585,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()
|
||||
@@ -1040,15 +1062,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())
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -437,7 +460,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
performBackAction(double = false)
|
||||
}
|
||||
pendingBackAction = pending
|
||||
uiHandler.postDelayed(pending, BACK_DOUBLE_PRESS_TIMEOUT_MS)
|
||||
// Android's own double-tap window (300 ms): the single action waits it out, so it is felt.
|
||||
uiHandler.postDelayed(pending, android.view.ViewConfiguration.getDoubleTapTimeout().toLong())
|
||||
}
|
||||
|
||||
private fun performBackAction(double: Boolean) {
|
||||
@@ -1554,16 +1578,27 @@ 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 {
|
||||
private const val TAG = "SessionActivity"
|
||||
private const val UNBUFFERED_SOURCES = InputDevice.SOURCE_CLASS_JOYSTICK or InputDevice.SOURCE_CLASS_TRACKBALL or InputDevice.SOURCE_CLASS_POSITION
|
||||
private const val BACK_DOUBLE_PRESS_TIMEOUT_MS = 500L
|
||||
private const val CURSOR_PAD_HOLD_MS = 1200L
|
||||
private const val DRAWER_HAT_THRESHOLD = 0.5f
|
||||
private const val DRAWER_STICK_ENTER_THRESHOLD = 0.55f
|
||||
|
||||
@@ -162,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,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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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