Compare commits

...
Author SHA1 Message Date
xXJsonDeruloXx 50e1b3379e fix: let the Steam client shut down cleanly when a session stops 2026-09-28 22:04:37 -04:00
Kurt Himebauch 1cc1ee994d feat: pair wireless debugging from a notification for the child-process gate (#67)
* feat: pair wireless debugging from a notification for the child-process gate

* fix: pair over loopback, handle Android 12 and 14 child-process routes, use D logo for notifications

* fix: read the adb tls port directly before falling back to mdns

* fix: end pairing on the same notification so progress never lingers
2026-09-28 21:37:10 -04:00
12 changed files with 647 additions and 117 deletions

No files matched your search

+3
View File
@@ -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"
@@ -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
@@ -431,6 +432,12 @@ class MainActivity : ComponentActivity() {
android.widget.Toast.makeText(this, "ADB command copied", android.widget.Toast.LENGTH_SHORT).show()
},
onDismissPhantomGate = { showPhantomGate = false },
onStartWirelessAdbPairing = { WirelessAdbPairingService.start(this) },
onOpenNotificationSettings = {
startActivity(Intent(android.provider.Settings.ACTION_APP_NOTIFICATION_SETTINGS)
.putExtra(android.provider.Settings.EXTRA_APP_PACKAGE, packageName)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
},
controller = ControllerActions(
onOsc = { o -> SessionPrefs.setOscMode(this, o); oscMode = o },
onTint = { t -> ControllerPrefs.setTint(this, t); refreshController() },
@@ -1040,15 +1047,18 @@ class MainActivity : ComponentActivity() {
}
private fun refreshPhantomStatus() {
phantomProcessStatus = PhantomProcessLimit.read(contentResolver)
phantomProcessStatus = PhantomProcessLimit.read(this)
phantomWarning = if (PhantomProcessLimit.blocksSteam(phantomProcessStatus)) {
"${PhantomProcessLimit.title(phantomProcessStatus)}. ${PhantomProcessLimit.instructions(phantomProcessStatus)}\n\n${PhantomProcessLimit.ADB_COMMAND}"
"${PhantomProcessLimit.title(phantomProcessStatus)}. ${PhantomProcessLimit.instructions(phantomProcessStatus)}\n\n${PhantomProcessLimit.adbCommand()}"
} else null
}
private fun openDeveloperOptions(displayId: Int?) {
val highlight = if (PhantomProcessLimit.hasDeveloperToggle() &&
WirelessAdbPairingService.stage.value == WirelessAdbPairingService.Stage.Idle) null else "toggle_adb_wireless"
val intent = Intent(android.provider.Settings.ACTION_APPLICATION_DEVELOPMENT_SETTINGS)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.apply { if (highlight != null) putExtra(":settings:fragment_args_key", highlight) }
try {
if (displayId == null) startActivity(intent)
else startActivity(intent, ActivityOptions.makeBasic().setLaunchDisplayId(displayId).toBundle())
@@ -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
}
}
@@ -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,
)
}
}
@@ -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
@@ -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