Merge pull request #7 from The412Banner/feature/session-suspend-audio-recovery

This commit is contained in:
Kurt Himebauch authored and GitHub committed 2026-09-23 11:01:00 -04:00
commit 4e791d486f
16 files changed
+691 -39

No files matched your search

+1 -1
View File
@@ -10,7 +10,7 @@ On Android 12+, if Steam exits without a log, turn off **Restrict child processe
## Build
Run `tools/build_local.sh` with Docker and the Android SDK/NDK installed. The APK is written to `app/build/outputs/apk/release/app-release.apk`. To install it on an attached device, run `tools/deploy_local.sh`.
Run `tools/build_local.sh` with Docker, Java 17, the Android SDK/NDK, and `zstd` installed. It builds the ARM64 audio sinks from PulseAudio 13.0 and packages them into the APK at `app/build/outputs/apk/release/app-release.apk`. Set `STEAMDECK_PA13_SOURCE_DIR` to an existing PulseAudio 13.0 source directory to skip downloading it. To install the APK on an attached device, run `tools/deploy_local.sh`.
## Limits
Binary file not shown.
@@ -153,6 +153,7 @@ class MainActivity : ComponentActivity() {
private var hdrOn by mutableStateOf(false)
private var hdrReason by mutableStateOf<String?>(null)
private var touchMode by mutableStateOf(SessionPrefs.TOUCH_AUTO)
private var suspendPolicy by mutableStateOf(SessionPrefs.SUSPEND_MANUAL)
private var oscMode by mutableStateOf(SessionPrefs.OSC_AUTO)
private var renderer by mutableStateOf("pixman")
private var gameStorage by mutableStateOf("")
@@ -390,6 +391,7 @@ class MainActivity : ComponentActivity() {
linuxSelected = if (mode == SessionService.MODE_STEAM) linuxSteam else linuxDesktop,
androidRows = androidRows, androidSelected = androidSelected,
touchMode = touchMode,
suspendPolicy = suspendPolicy,
oscMode = if (mode == SessionService.MODE_STEAM) oscMode else null,
directAudio = if (mode == SessionService.MODE_STEAM) directAudio else null,
mic = if (mode == SessionService.MODE_STEAM) mic else null,
@@ -413,6 +415,7 @@ class MainActivity : ComponentActivity() {
onImportAndroid = { pickAndroidDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a display driver (AdrenoTools zip)")) },
onRemoveAndroid = { id -> TurnipDriver(this).remove(id); refreshDrivers() },
onTouch = { t -> SessionPrefs.setTouchMode(this, t); touchMode = t },
onSuspendPolicy = { policy -> SessionPrefs.setSuspendPolicy(this, mode, policy); suspendPolicy = policy },
onOsc = { o -> SessionPrefs.setOscMode(this, o); oscMode = o },
onDirectAudio = { on -> SessionPrefs.setDirectAudio(this, on); directAudio = on },
onMic = { on ->
@@ -503,6 +506,7 @@ class MainActivity : ComponentActivity() {
hdrOn = SessionPrefs.hdr(this, mode)
hdrReason = com.steamdeck.launcher.wayland.HdrSupport.probe(this).reason
touchMode = SessionPrefs.touchMode(this)
suspendPolicy = SessionPrefs.suspendPolicy(this, mode)
oscMode = SessionPrefs.oscMode(this)
directAudio = SessionPrefs.directAudio(this)
mic = SessionPrefs.micEnabled(this)
@@ -49,6 +49,7 @@ import com.steamdeck.launcher.ui.LoadingOverlay
import com.steamdeck.launcher.ui.ProtonDialog
import com.steamdeck.launcher.ui.ProtonRow
import com.steamdeck.launcher.ui.SessionDrawer
import com.steamdeck.launcher.ui.SessionPausedOverlay
import com.steamdeck.launcher.ui.SteamDeckTheme
import com.steamdeck.launcher.wayland.CompositorHost
import com.steamdeck.launcher.wayland.WaylandCompositor
@@ -203,6 +204,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
onRemove = { id -> ProtonExtras.tools.first { it.id == id }.let { ProtonExtras.remove(this@SessionActivity, it) }; refreshProtons() },
onDismiss = { showProtons = false },
)
if (SessionState.suspended) SessionPausedOverlay {
SessionService.resume(this@SessionActivity)
}
}
}
})
@@ -682,8 +686,31 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
// ── Lifecycle ───────────────────────────────────────────────────────────────────────────
override fun onStart() {
super.onStart()
SessionService.setActivityVisible(this, true)
}
override fun onStop() {
SessionService.setActivityVisible(this, false)
super.onStop()
}
override fun onNewIntent(intent: android.content.Intent) {
super.onNewIntent(intent)
setIntent(intent)
if (intent.action == SessionService.ACTION_RESUME) {
intent.action = null
SessionService.resume(this)
}
}
override fun onResume() {
super.onResume()
if (intent?.action == SessionService.ACTION_RESUME) {
intent.action = null
SessionService.resume(this)
}
(getSystemService(INPUT_SERVICE) as? InputManager)
?.registerInputDeviceListener(deviceListener, Handler(Looper.getMainLooper()))
updateOnScreenControls()
@@ -38,7 +38,7 @@ public class DirectAudioRelayComponent extends SessionPart {
private File logFile;
/** Null when the microphone was not asked for; the helper then opens no input stream. */
private final File micFifoPath;
private int pid = -1;
private volatile int pid = -1;
public DirectAudioRelayComponent(File socketPath, File micFifoPath) {
this.socketPath = socketPath;
@@ -129,4 +129,9 @@ public class DirectAudioRelayComponent extends SessionPart {
pid = -1;
}
}
@Override
public int suspendPid() {
return pid;
}
}
@@ -15,23 +15,20 @@ import java.net.URL;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.concurrent.TimeUnit;
/**
* PulseAudio 13.0 running in the app process, with Android's AAudio as its sink, so the guest's
* libpulse clients (Steam and everything it launches) have a server to talk to. The socket lives
* in the app's files directory and is bound into the session at its own path, so PULSE_SERVER
* needs no translating.
*
* <p>Ported down from Bannerlator's component: no sink suspend/resume and no route-change
* recreate - those need the pasink native client, and a session here is a foreground activity
* that does not background the way a game container does.
*/
public class PulseAudioComponent extends SessionPart {
private static final String TAG = "PulseAudio";
/** Where the guest reaches the daemon; the session exports PULSE_SERVER=unix:<this>. */
public static final String SOCKET_NAME = "PS0";
/** Identifies the bundled pulseaudio.tzst; a change here re-unpacks it over what a device has. */
private static final String BUNDLE_STAMP = "2026-09-23-pa13-relay-sink-r3";
private static final String BUNDLE_STAMP = "2026-09-23-pa13-suspend-r4";
private final File workingDir;
/** Where the daemon's own output is kept for this session, or null for logcat only. */
@@ -45,7 +42,7 @@ public class PulseAudioComponent extends SessionPart {
private final String micFifoPath;
/** The DirectAudio relay's socket when the client's output should go through it, else null. */
private String relaySocketPath;
private int pid = -1;
private volatile int pid = -1;
public PulseAudioComponent(Context context) {
this(context, null);
@@ -94,6 +91,7 @@ public class PulseAudioComponent extends SessionPart {
File stamp = new File(modulesDir, ".bundle");
String have = FileUtils.readString(stamp);
if (!new File(modulesDir, "arm64/module-aaudio-sink.so").isFile()
|| !new File(workingDir, "pactl").isFile()
|| have == null || !BUNDLE_STAMP.equals(have.trim())) {
Log.i(TAG, "unpacking pulseaudio.tzst (" + BUNDLE_STAMP + "; had " + have + ")");
FileUtils.delete(modulesDir);
@@ -103,6 +101,8 @@ public class PulseAudioComponent extends SessionPart {
Log.e(TAG, "pulseaudio.tzst did not unpack");
}
}
File pactl = new File(workingDir, "pactl");
if (pactl.isFile()) FileUtils.chmod(pactl, 0771);
copyFromLibraryDir();
//noinspection ResultOfMethodCallIgnored
@@ -169,6 +169,45 @@ public class PulseAudioComponent extends SessionPart {
}
}
public boolean setSinkSuspended(boolean suspended) {
File pactl = new File(workingDir, "pactl");
if (!pactl.isFile() || pid <= 1) {
Log.w(TAG, "cannot change sink state: PulseAudio control client or server unavailable");
return false;
}
java.lang.Process process = null;
try {
File modules = new File(workingDir, "modules/arm64");
ProcessBuilder builder = new ProcessBuilder(
pactl.getAbsolutePath(), "suspend-sink", "@DEFAULT_SINK@", Boolean.toString(suspended));
builder.directory(workingDir);
builder.redirectErrorStream(true);
builder.redirectOutput(new File("/dev/null"));
builder.environment().put("LD_LIBRARY_PATH", "/system/lib64:" + modules + ":" + workingDir);
builder.environment().put("HOME", workingDir.getAbsolutePath());
builder.environment().put("TMPDIR", workingDir.getAbsolutePath());
builder.environment().put("PULSE_SERVER", "unix:" + socket().getAbsolutePath());
process = builder.start();
if (!process.waitFor(5, TimeUnit.SECONDS)) {
process.destroyForcibly();
Log.w(TAG, "timed out changing sink state to suspended=" + suspended);
return false;
}
int status = process.exitValue();
if (status != 0) Log.w(TAG, "sink state change failed with exit " + status);
return status == 0;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
Log.w(TAG, "interrupted changing sink state", e);
return false;
} catch (Exception e) {
Log.w(TAG, "could not change sink state", e);
return false;
} finally {
if (process != null && process.isAlive()) process.destroyForcibly();
}
}
/** The session's audio log, appended to by both the daemon and the relay helper. */
private java.io.PrintWriter openLog() {
if (logFile == null) return null;
@@ -23,4 +23,8 @@ public abstract class SessionPart {
public abstract void start();
public abstract void stop();
public int suspendPid() {
return -1;
}
}
@@ -4,6 +4,10 @@ import android.content.Context
/** The in-session switches: the HUD and how the on-screen controls decide to appear. */
object SessionPrefs {
const val SUSPEND_AUTO = "auto"
const val SUSPEND_MANUAL = "manual"
const val SUSPEND_NEVER = "never"
const val OSC_AUTO = "auto"
const val OSC_ALWAYS = "always"
const val OSC_STEAM_QAM = "steam-qam"
@@ -306,6 +310,17 @@ object SessionPrefs {
*/
fun prefMode(mode: String): String = if (mode == SessionService.MODE_RUN) SessionService.MODE_STEAM else mode
fun suspendPolicy(context: Context, mode: String): String =
prefs(context).getString("suspendPolicy.${prefMode(mode)}", SUSPEND_MANUAL)
?.takeIf { it == SUSPEND_AUTO || it == SUSPEND_MANUAL || it == SUSPEND_NEVER }
?: SUSPEND_MANUAL
fun setSuspendPolicy(context: Context, mode: String, policy: String) {
val normalized = policy.takeIf { it == SUSPEND_AUTO || it == SUSPEND_MANUAL || it == SUSPEND_NEVER }
?: SUSPEND_MANUAL
prefs(context).edit().putString("suspendPolicy.${prefMode(mode)}", normalized).apply()
}
// ── Game storage ────────────────────────────────────────────────────────────────────────
/**
@@ -9,12 +9,16 @@ import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.BroadcastReceiver
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.net.wifi.WifiManager
import android.os.Build
import android.os.Environment
import android.os.IBinder
import android.os.Handler
import android.os.Looper
import android.os.PowerManager
import android.util.Log
import com.steamdeck.launcher.R
@@ -52,22 +56,83 @@ import java.util.Locale
* The activity comes and goes on top of this; see [com.steamdeck.launcher.wayland.CompositorHost].
*/
class SessionService : Service() {
private val components = ArrayList<SessionPart>()
private val components = java.util.concurrent.CopyOnWriteArrayList<SessionPart>()
private var wakeLock: PowerManager.WakeLock? = null
private var wifiLock: WifiManager.WifiLock? = null
private var sessionPid = -1
private val mainHandler = Handler(Looper.getMainLooper())
private var suspendController: SessionSuspendController? = null
private var suspendPolicy = SessionPrefs.SUSPEND_MANUAL
private var activityVisible = true
private var screenOn = true
private var manualPauseRequested = false
private var suspendOperationPending = false
private var suspendAttemptFailed = false
private var screenReceiverRegistered = false
private val screenReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.action) {
Intent.ACTION_SCREEN_OFF -> screenOn = false
Intent.ACTION_SCREEN_ON -> screenOn = true
else -> return
}
suspendAttemptFailed = false
updateSuspendPolicy()
}
}
/** Counts sessions this service has started; a process exit from an earlier one is ignored. */
private var sessionGen = 0
override fun onCreate() {
super.onCreate()
val power = getSystemService(Context.POWER_SERVICE) as? PowerManager
screenOn = power?.isInteractive ?: true
registerReceiver(screenReceiver, IntentFilter().apply {
addAction(Intent.ACTION_SCREEN_OFF)
addAction(Intent.ACTION_SCREEN_ON)
})
screenReceiverRegistered = true
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_STOP) {
Log.i(TAG, "stop requested from the notification")
stopSession(0)
return START_NOT_STICKY
when (intent?.action) {
ACTION_STOP -> {
Log.i(TAG, "stop requested from the notification")
stopSession(0)
return START_NOT_STICKY
}
ACTION_ACTIVITY_VISIBLE -> {
activityVisible = true
suspendAttemptFailed = false
updateSuspendPolicy()
return START_NOT_STICKY
}
ACTION_ACTIVITY_HIDDEN -> {
activityVisible = false
suspendAttemptFailed = false
updateSuspendPolicy()
return START_NOT_STICKY
}
ACTION_RESUME -> {
activityVisible = true
screenOn = (getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: screenOn
manualPauseRequested = false
suspendAttemptFailed = false
updateSuspendPolicy()
return START_NOT_STICKY
}
}
startForeground(NOTIFICATION_ID, buildNotification())
if (SessionState.running) return START_NOT_STICKY
SessionState.mode = intent?.getStringExtra(EXTRA_MODE) ?: MODE_STEAM
suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode)
activityVisible = true
screenOn = (getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: true
manualPauseRequested = false
suspendOperationPending = false
suspendAttemptFailed = false
suspendController = null
SessionState.suspended = false
SessionState.program = intent?.getStringExtra(EXTRA_PROGRAM)
SessionState.programArgs = intent?.getStringArrayExtra(EXTRA_PROGRAM_ARGS)?.toList().orEmpty()
SessionState.steamUi = intent?.getStringExtra(EXTRA_STEAM_UI)
@@ -460,6 +525,19 @@ class SessionService : Service() {
stopSession(status ?: -1)
}, null)
Log.i(TAG, "session pid $sessionPid, log ${sessionLog.path}")
if (gen == sessionGen && SessionState.running) {
suspendController = SessionSuspendController(
sessionRoot = { sessionPid },
helperRoots = { components.mapNotNull { it.suspendPid().takeIf { pid -> pid > 1 } } },
suspendAudio = {
if (!pulse.setSinkSuspended(true)) Log.w(TAG, "could not suspend audio sink")
},
resumeAudio = {
if (!pulse.setSinkSuspended(false)) Log.w(TAG, "could not resume audio sink")
},
)
mainHandler.post { updateSuspendPolicy() }
}
}
private fun teardown(prootPid: Int) {
@@ -585,9 +663,68 @@ class SessionService : Service() {
launchWatcher = watcher
}
private fun updateSuspendPolicy() {
if (!SessionState.running) return
if (suspendPolicy == SessionPrefs.SUSPEND_MANUAL && (!activityVisible || !screenOn)) {
manualPauseRequested = true
}
val shouldSuspend = when (suspendPolicy) {
SessionPrefs.SUSPEND_AUTO -> !activityVisible || !screenOn
SessionPrefs.SUSPEND_MANUAL -> manualPauseRequested
else -> false
}
val controller = suspendController ?: return
if (suspendOperationPending || suspendAttemptFailed || shouldSuspend == SessionState.suspended) return
suspendOperationPending = true
if (shouldSuspend) {
controller.freeze { success ->
mainHandler.post {
if (!SessionState.running) return@post
suspendOperationPending = false
if (success) {
SessionState.suspended = true
releaseLocks()
refreshNotification()
} else {
suspendAttemptFailed = true
Log.w(TAG, "could not confirm that the session stopped")
}
updateSuspendPolicy()
}
}
} else {
acquireLocks()
controller.resume { success ->
mainHandler.post {
if (!SessionState.running) return@post
suspendOperationPending = false
if (success) {
SessionState.suspended = false
refreshNotification()
} else {
suspendAttemptFailed = true
Log.w(TAG, "could not confirm that the session resumed")
}
updateSuspendPolicy()
}
}
}
}
private fun finishSessionStop(status: Int) {
mainHandler.post {
SessionState.suspended = false
releaseLocks()
SessionState.notifyEnded(status)
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
}
private fun stopSession(status: Int) {
if (!SessionState.running) return
SessionState.running = false
suspendOperationPending = false
launchWatcher?.stopWatching()
launchWatcher = null
// proot's --kill-on-exit takes the guest tree down only if proot gets to run it, and
@@ -596,12 +733,11 @@ class SessionService : Service() {
// for over an hour, and the reaper could not even open /proc to kill them. So: SIGTERM,
// a grace for proot's own cleanup, SIGKILL only if it will not go, then a sweep of the
// tree it had - snapshotted first, each pid checked against its start time so a number
// reused by a new process is never touched. Same shape as Bannerlator's fix (4509d788).
if (sessionPid != -1) {
val prootPid = sessionPid
sessionPid = -1
Thread({ teardown(prootPid) }, "session-teardown").start()
}
// reused by a new process is never touched.
val prootPid = sessionPid
sessionPid = -1
val controller = suspendController
suspendController = null
// On its own thread, never here: stopSession runs on the main thread (the notification's
// Stop action arrives there), and collecting means copying the compositor's log, scrubbing
// every Steam log line by line - 42 files on one measured run - and waiting for logcat to
@@ -618,10 +754,18 @@ class SessionService : Service() {
}
components.clear()
FakeInputWriter.releaseAllRingSlots()
releaseLocks()
SessionState.notifyEnded(status)
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
val finishAfterTeardown: () -> Unit = {
if (prootPid > 1) {
Thread({
teardown(prootPid)
finishSessionStop(status)
}, "session-teardown").start()
} else {
finishSessionStop(status)
}
}
if (prootPid > 1) acquireLocks()
if (controller != null) controller.closeAndResume(finishAfterTeardown) else finishAfterTeardown()
}
/**
@@ -636,6 +780,10 @@ class SessionService : Service() {
override fun onDestroy() {
stopSession(0)
if (screenReceiverRegistered) {
unregisterReceiver(screenReceiver)
screenReceiverRegistered = false
}
super.onDestroy()
}
@@ -645,12 +793,12 @@ class SessionService : Service() {
private fun acquireLocks() {
try {
val power = getSystemService(Context.POWER_SERVICE) as? PowerManager
wakeLock = power?.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "SteamDeck:session")?.apply {
setReferenceCounted(false)
// Capped, so a crash on some path cannot pin the CPU awake for good. A session
// longer than this re-acquires from the notification tap; nothing else needs it.
acquire(12L * 60L * 60L * 1000L)
if (wakeLock?.isHeld != true) {
val power = getSystemService(Context.POWER_SERVICE) as? PowerManager
wakeLock = power?.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "SteamDeck:session")?.apply {
setReferenceCounted(false)
acquire(12L * 60L * 60L * 1000L)
}
}
Log.i(TAG, "wake lock held=${wakeLock?.isHeld}")
} catch (t: Throwable) {
@@ -659,11 +807,13 @@ class SessionService : Service() {
try {
val wifi = applicationContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager
@Suppress("DEPRECATION") // deprecated from API 29, still honoured; targetSdk is 28
wifiLock = wifi?.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "SteamDeck:session-wifi")
?.apply {
setReferenceCounted(false)
acquire()
}
if (wifiLock?.isHeld != true) {
wifiLock = wifi?.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "SteamDeck:session-wifi")
?.apply {
setReferenceCounted(false)
acquire()
}
}
Log.i(TAG, "wifi lock held=${wifiLock?.isHeld}")
} catch (t: Throwable) {
// A partial wake lock keeps the process alive but does not stop WiFi power-save from
@@ -711,11 +861,23 @@ class SessionService : Service() {
this, 1, Intent(this, SessionService::class.java).setAction(ACTION_STOP),
PendingIntent.FLAG_IMMUTABLE,
)
val resume = PendingIntent.getActivity(
this, 2,
Intent(this, SessionActivity::class.java)
.setAction(ACTION_RESUME)
.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT),
PendingIntent.FLAG_IMMUTABLE,
)
return Notification.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_stat_session)
.setContentTitle(getString(R.string.app_name))
.setContentText(getString(R.string.session_notification))
.setContentText(getString(if (SessionState.suspended) R.string.session_notification_paused else R.string.session_notification))
.setContentIntent(open)
.apply {
if (SessionState.suspended) {
addAction(Notification.Action.Builder(null, getString(R.string.resume_session), resume).build())
}
}
.addAction(Notification.Action.Builder(null, getString(R.string.stop_session), stop).build())
.setOngoing(true)
.setShowWhen(false)
@@ -723,6 +885,10 @@ class SessionService : Service() {
.build()
}
private fun refreshNotification() {
getSystemService(NotificationManager::class.java)?.notify(NOTIFICATION_ID, buildNotification())
}
companion object {
private const val TAG = "SessionService"
/** Downloads file whose contents become TU_DEBUG inside the session, e.g. "sysmem". */
@@ -732,6 +898,9 @@ class SessionService : Service() {
private const val CHANNEL_ID = "session"
private const val NOTIFICATION_ID = 1001
const val ACTION_STOP = "com.steamdeck.launcher.STOP_SESSION"
const val ACTION_RESUME = "com.steamdeck.launcher.RESUME_SESSION"
private const val ACTION_ACTIVITY_VISIBLE = "com.steamdeck.launcher.ACTIVITY_VISIBLE"
private const val ACTION_ACTIVITY_HIDDEN = "com.steamdeck.launcher.ACTIVITY_HIDDEN"
/** Command lines that can only belong to a session of ours. */
private val STRAGGLERS = listOf("bannerlator-session", "gamescope", "Xwayland", "steamrtarm64",
"steamwebhelper", "linuxfs/opt/android-host/proot", "pulseaudio/libpulseaudio.so")
@@ -768,5 +937,16 @@ class SessionService : Service() {
fun stop(context: Context) {
context.startService(Intent(context, SessionService::class.java).setAction(ACTION_STOP))
}
fun setActivityVisible(context: Context, visible: Boolean) {
if (!SessionState.running) return
val action = if (visible) ACTION_ACTIVITY_VISIBLE else ACTION_ACTIVITY_HIDDEN
context.startService(Intent(context, SessionService::class.java).setAction(action))
}
fun resume(context: Context) {
if (!SessionState.running) return
context.startService(Intent(context, SessionService::class.java).setAction(ACTION_RESUME))
}
}
}
@@ -1,6 +1,9 @@
package com.steamdeck.launcher.session
import java.io.File
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
/**
* The little that the activity and the service have to agree on. Process-wide rather than passed
@@ -10,6 +13,7 @@ import java.io.File
object SessionState {
@Volatile
var running = false
var suspended by mutableStateOf(false)
/** MODE_RUN: the program inside the runtime the session was started for. */
var program: String? = null
var programArgs: List<String> = emptyList()
@@ -0,0 +1,246 @@
package com.steamdeck.launcher.session
import android.os.Process
import java.io.File
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
internal class SessionSuspendController(
private val sessionRoot: () -> Int,
private val helperRoots: () -> List<Int>,
private val suspendAudio: () -> Unit,
private val resumeAudio: () -> Unit,
) {
private enum class Role { SESSION_ROOT, SESSION_CHILD, HELPER_ROOT, HELPER_CHILD }
private data class ProcessRecord(
val pid: Int,
val started: Long,
val role: Role,
)
private data class ProcessInfo(
val pid: Int,
val parent: Int,
val started: Long,
val state: Char,
)
private val executor: ExecutorService = Executors.newSingleThreadExecutor()
@Volatile private var closing = false
private var frozen = emptyList<ProcessRecord>()
fun freeze(completion: (Boolean) -> Unit) {
submit {
if (closing) {
completion(false)
return@submit
}
if (frozen.isNotEmpty()) {
val fullyFrozen = frozen.any { it.role == Role.SESSION_ROOT } && frozen.all(::isStoppedOrGone)
if (!fullyFrozen && restore(frozen)) frozen = emptyList()
completion(fullyFrozen)
return@submit
}
val records = LinkedHashMap<Pair<Int, Long>, ProcessRecord>()
val root = sessionRoot()
if (root <= 1) {
completion(false)
return@submit
}
runAudio(suspendAudio)
val helpers = helperRoots().filter { it > 1 && it != root }.distinct()
var stablePasses = 0
var complete = false
repeat(10) { pass ->
if (closing) {
restore(records.values.toList())
runAudio(resumeAudio)
completion(false)
return@submit
}
val includeRoots = pass >= 2
addSnapshot(records, root, helpers, includeRoots)
stop(records.values.toList(), includeRoots)
Thread.sleep(if (pass == 0) 70L else 35L)
val rootCaptured = records.values.any { it.role == Role.SESSION_ROOT }
if (rootCaptured && records.isNotEmpty() && records.values.all(::isStoppedOrGone)) {
stablePasses++
if (stablePasses >= 2) complete = true
} else {
stablePasses = 0
}
}
if (complete) {
frozen = records.values.toList()
completion(true)
} else {
val restored = restore(records.values.toList())
runAudio(resumeAudio)
if (!restored) frozen = records.values.toList()
completion(false)
}
}
}
fun resume(completion: (Boolean) -> Unit) {
submit {
val records = frozen
runAudio(resumeAudio)
val resumed = restore(records)
if (resumed) frozen = emptyList()
completion(resumed)
}
}
fun closeAndResume(completion: () -> Unit) {
closing = true
submit {
restore(frozen)
frozen = emptyList()
completion()
executor.shutdown()
}
}
private fun submit(task: () -> Unit) {
try {
executor.execute {
try {
task()
} catch (_: InterruptedException) {
Thread.currentThread().interrupt()
}
}
} catch (_: RuntimeException) {
task()
}
}
private fun runAudio(action: () -> Unit) {
try {
action()
} catch (_: Exception) {
}
}
private fun addSnapshot(
records: MutableMap<Pair<Int, Long>, ProcessRecord>,
root: Int,
helpers: List<Int>,
includeRoots: Boolean,
) {
val processes = readProcesses()
val byPid = processes.associateBy { it.pid }
for (process in descendants(root, processes, byPid)) {
records[process.pid to process.started] = ProcessRecord(process.pid, process.started, Role.SESSION_CHILD)
}
for (helper in helpers) {
for (process in descendants(helper, processes, byPid)) {
records[process.pid to process.started] = ProcessRecord(process.pid, process.started, Role.HELPER_CHILD)
}
}
if (includeRoots) {
byPid[root]?.let { records[it.pid to it.started] = ProcessRecord(it.pid, it.started, Role.SESSION_ROOT) }
for (helper in helpers) {
byPid[helper]?.let { records[it.pid to it.started] = ProcessRecord(it.pid, it.started, Role.HELPER_ROOT) }
}
}
}
private fun descendants(root: Int, processes: List<ProcessInfo>, byPid: Map<Int, ProcessInfo>): List<ProcessInfo> {
if (root !in byPid) return emptyList()
val children = HashMap<Int, MutableList<ProcessInfo>>()
for (process in processes) children.getOrPut(process.parent) { ArrayList() }.add(process)
val excluded = Process.myPid()
val found = ArrayList<ProcessInfo>()
val seen = HashSet<Int>()
val queue = ArrayDeque<Int>().apply { add(root) }
while (queue.isNotEmpty()) {
val parent = queue.removeFirst()
for (child in children[parent].orEmpty()) {
if (child.pid <= 1 || child.pid == excluded || !seen.add(child.pid)) continue
found.add(child)
queue.add(child.pid)
}
}
return found
}
private fun readProcesses(): List<ProcessInfo> =
File("/proc").listFiles().orEmpty().mapNotNull { entry ->
val pid = entry.name.toIntOrNull() ?: return@mapNotNull null
readProcess(pid)
}
private fun readProcess(pid: Int): ProcessInfo? {
val stat = try {
File("/proc/$pid/stat").readText()
} catch (_: Exception) {
return null
}
val close = stat.lastIndexOf(')')
if (close < 0 || close + 2 >= stat.length) return null
val fields = stat.substring(close + 2).trim().split(Regex("\\s+"))
if (fields.size < 20) return null
return try {
ProcessInfo(pid, fields[1].toInt(), fields[19].toLong(), fields[0].first())
} catch (_: NumberFormatException) {
null
}
}
private fun stop(records: List<ProcessRecord>, includeRoots: Boolean) {
val descendants = records.filter { it.role == Role.SESSION_CHILD || it.role == Role.HELPER_CHILD }
val session = records.filter { it.role == Role.SESSION_ROOT }
val helpers = records.filter { it.role == Role.HELPER_ROOT }
for (record in descendants) send(record, SIGSTOP)
if (includeRoots) {
for (record in session) send(record, SIGSTOP)
for (record in helpers) send(record, SIGSTOP)
}
}
private fun restore(records: List<ProcessRecord>): Boolean {
val ordered = records.sortedBy {
when (it.role) {
Role.SESSION_ROOT -> 0
Role.HELPER_ROOT -> 1
Role.SESSION_CHILD -> 2
Role.HELPER_CHILD -> 3
}
}
for (record in ordered) send(record, SIGCONT)
repeat(8) {
Thread.sleep(25L)
if (ordered.all(::isRunningOrGone)) return true
}
return ordered.all(::isRunningOrGone)
}
private fun send(record: ProcessRecord, signal: Int) {
val current = readProcess(record.pid) ?: return
if (current.started != record.started) return
try {
Process.sendSignal(record.pid, signal)
} catch (_: Throwable) {
}
}
private fun isStoppedOrGone(record: ProcessRecord): Boolean {
val current = readProcess(record.pid) ?: return true
if (current.started != record.started) return true
return current.state == 'T' || current.state == 't' || current.state == 'Z' || current.state == 'X'
}
private fun isRunningOrGone(record: ProcessRecord): Boolean {
val current = readProcess(record.pid) ?: return true
if (current.started != record.started) return true
return current.state != 'T' && current.state != 't'
}
private companion object {
const val SIGSTOP = 19
const val SIGCONT = 18
}
}
@@ -26,6 +26,8 @@ class ModeSettings(
val androidRows: List<DriverRow>,
val androidSelected: String,
val touchMode: String,
val suspendPolicy: String,
/** Steam only. */
val oscMode: String?,
val directAudio: Boolean?,
val mic: Boolean?,
@@ -56,6 +58,7 @@ class ModeSettingsActions(
val onImportAndroid: () -> Unit,
val onRemoveAndroid: (String) -> Unit,
val onTouch: (String) -> Unit,
val onSuspendPolicy: (String) -> Unit,
val onOsc: (String) -> Unit,
val onDirectAudio: (Boolean) -> Unit,
val onMic: (Boolean) -> Unit,
@@ -135,6 +138,20 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
note = "Auto shows all controls without a controller. Steam + QAM shows only those buttons.", onPick = a.onOsc,
)
}
SettingsGroup("Session") {
ChoiceRow(
host, "suspend", "Background behavior",
"How this session behaves when the app leaves the screen or the display turns off.",
listOf(
SessionPrefs.SUSPEND_AUTO to "Auto",
SessionPrefs.SUSPEND_MANUAL to "Manual",
SessionPrefs.SUSPEND_NEVER to "Never",
),
s.suspendPolicy,
note = "Auto pauses in the background and resumes when visible. Manual pauses there and waits for Resume. Never keeps the session running.",
onPick = a.onSuspendPolicy,
)
}
if (steam && s.steamChannel != null) SettingsGroup("Client") {
ChoiceRow(
host, "channel", "Client branch", "The Steam client build the session forces. Applies at the next session start; the client may update itself once.",
@@ -110,6 +110,28 @@ fun LoadingOverlay(step: String, percent: Int, elapsed: String, hint: String, en
}
}
@Composable
fun SessionPausedOverlay(onResume: () -> Unit) {
val interactionSource = remember { MutableInteractionSource() }
Box(
modifier = Modifier
.fillMaxSize()
.background(Color(0xF20B0D10))
.clickable(interactionSource = interactionSource, indication = null) {},
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(32.dp),
) {
OutlinedButton(onClick = onResume) {
Text("Resume session")
}
}
}
}
/** Everything the drawer shows and does. */
class DrawerActions(
val steam: Boolean,
val hudOn: Boolean,
+2
View File
@@ -9,6 +9,8 @@
<string name="session_channel">Session</string>
<string name="session_channel_description">Keeps Steam running in the background</string>
<string name="session_notification">Steam is running</string>
<string name="session_notification_paused">Session paused - tap Resume when ready</string>
<string name="resume_session">Resume</string>
<string name="stop_session">Stop session</string>
<string name="frame_gen_title">Frame generation</string>
</resources>
+18 -2
View File
@@ -12,9 +12,25 @@ API=26
HERE=$(cd "$(dirname "$0")" && pwd)
REPO=$(cd "$HERE/../.." && pwd)
LIBS="$REPO/app/src/main/jniLibs/arm64-v8a"
TOOLCHAIN="$NDK/toolchains/llvm/prebuilt/linux-x86_64/bin"
case "$(uname -s):$(uname -m)" in
Darwin:arm64) NDK_HOST=darwin-arm64 ;;
Darwin:x86_64) NDK_HOST=darwin-x86_64 ;;
Linux:x86_64) NDK_HOST=linux-x86_64 ;;
Linux:aarch64|Linux:arm64) NDK_HOST=linux-aarch64 ;;
*) echo "Unsupported build host: $(uname -s) $(uname -m)" >&2; exit 1 ;;
esac
TOOLCHAIN="$NDK/toolchains/llvm/prebuilt/$NDK_HOST/bin"
if [[ ! -d "$TOOLCHAIN" && "$NDK_HOST" == darwin-arm64 && -d "$NDK/toolchains/llvm/prebuilt/darwin-x86_64/bin" ]]; then
TOOLCHAIN="$NDK/toolchains/llvm/prebuilt/darwin-x86_64/bin"
fi
if [[ ! -d "$TOOLCHAIN" && "$NDK_HOST" == linux-aarch64 && -d "$NDK/toolchains/llvm/prebuilt/linux-x86_64/bin" ]]; then
TOOLCHAIN="$NDK/toolchains/llvm/prebuilt/linux-x86_64/bin"
fi
CC="$TOOLCHAIN/aarch64-linux-android${API}-clang"
test -x "$CC"
if [[ ! -x "$CC" ]]; then
echo "Android NDK toolchain not found at $TOOLCHAIN" >&2
exit 1
fi
test -f "$PA_SRC/src/pulse/version.h.in"
INC=$(mktemp -d)
mkdir -p "$INC/pulse"
+73 -2
View File
@@ -86,18 +86,89 @@ if [[ -z "${ndk_version}" || ! -d "${sdk_dir}/ndk/${ndk_version}" ]]; then
exit 1
fi
for tool in curl tar zstd; do
if ! command -v "${tool}" >/dev/null 2>&1; then
echo "${tool} is required to build the bundled audio modules." >&2
exit 1
fi
done
staging_dir=$(mktemp -d "${TMPDIR:-/tmp}/steamdeck-build.XXXXXX")
bundle_asset="${repo_root}/app/src/main/assets/pulseaudio.tzst"
bundle_backup="${staging_dir}/pulseaudio.original.tzst"
bundle_replaced=0
cleanup() {
local exit_code=$?
trap - EXIT
if [[ "${bundle_replaced}" == 1 ]]; then
cp -p "${bundle_backup}" "${bundle_asset}" || exit_code=1
fi
rm -rf -- "${staging_dir}" || exit_code=1
exit "${exit_code}"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
pa_source=${STEAMDECK_PA13_SOURCE_DIR:-"${staging_dir}/pulseaudio-13.0"}
if [[ -z "${STEAMDECK_PA13_SOURCE_DIR:-}" ]]; then
curl -fsSL -o "${staging_dir}/pulseaudio-13.0.tar.gz" \
https://github.com/pulseaudio/pulseaudio/archive/refs/tags/v13.0.tar.gz
mkdir -p "${pa_source}"
tar -xzf "${staging_dir}/pulseaudio-13.0.tar.gz" \
-C "${pa_source}" --strip-components=1
fi
if [[ ! -f "${pa_source}/src/pulse/version.h.in" ]]; then
echo "PulseAudio 13.0 source not found at ${pa_source}; set STEAMDECK_PA13_SOURCE_DIR." >&2
exit 1
fi
export NDK="${sdk_dir}/ndk/${ndk_version}"
sink_output="${staging_dir}/sink-out"
"${repo_root}/tools/aaudio-sink/build.sh" "${pa_source}" "${sink_output}"
cp -p "${bundle_asset}" "${bundle_backup}"
bundle_dir="${staging_dir}/pulseaudio-bundle"
mkdir -p "${bundle_dir}"
zstd -dc "${bundle_asset}" | tar -xf - -C "${bundle_dir}"
if [[ -e "${bundle_dir}/modules/arm64/module-aaudio-sink.so" \
|| -e "${bundle_dir}/modules/arm64/module-directaudio-sink.so" ]]; then
echo "The committed audio bundle already contains a built ARM64 sink." >&2
exit 1
fi
install -m755 "${sink_output}/module-aaudio-sink.so" \
"${bundle_dir}/modules/arm64/module-aaudio-sink.so"
install -m755 "${sink_output}/module-directaudio-sink.so" \
"${bundle_dir}/modules/arm64/module-directaudio-sink.so"
tar -cf - -C "${bundle_dir}" . | zstd -19 -c > "${staging_dir}/pulseaudio.tzst"
bundle_replaced=1
mv "${staging_dir}/pulseaudio.tzst" "${bundle_asset}"
cd "${repo_root}"
./gradlew assembleRelease -PndkVersion="${ndk_version}"
cp -p "${bundle_backup}" "${bundle_asset}"
bundle_replaced=0
apk="${repo_root}/app/build/outputs/apk/release/app-release.apk"
audio_check="${staging_dir}/audio-check"
mkdir -p "${audio_check}"
unzip -p "${apk}" assets/pulseaudio.tzst | zstd -dc | tar -xf - -C "${audio_check}"
for audio_file in \
pactl \
modules/arm64/module-aaudio-sink.so \
modules/arm64/module-directaudio-sink.so; do
if [[ ! -f "${audio_check}/${audio_file}" ]]; then
echo "APK audio bundle is missing ${audio_file}." >&2
exit 1
fi
done
build_tools=$(find "${sdk_dir}/build-tools" -mindepth 1 -maxdepth 1 -type d -print | sort -V | tail -1)
if [[ ! -x "${build_tools}/zipalign" || ! -x "${build_tools}/apksigner" ]]; then
echo "Android build-tools with zipalign/apksigner are required under ${sdk_dir}/build-tools." >&2
exit 1
fi
staging_dir=$(mktemp -d "${TMPDIR:-/tmp}/steamdeck-apk.XXXXXX")
trap 'rm -rf -- "${staging_dir}"' EXIT
"${build_tools}/zipalign" -p -f 4 "${apk}" "${staging_dir}/app-release.aligned.apk"
"${build_tools}/apksigner" sign \
--ks keystore/testkey.p12 --ks-type PKCS12 --ks-pass pass:android \