diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml
new file mode 100644
index 000000000..70db082b2
--- /dev/null
+++ b/app/src/debug/AndroidManifest.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
diff --git a/app/src/debug/java/com/droiddeck/launcher/agent/AgentBridgeProvider.kt b/app/src/debug/java/com/droiddeck/launcher/agent/AgentBridgeProvider.kt
new file mode 100644
index 000000000..b89a06f85
--- /dev/null
+++ b/app/src/debug/java/com/droiddeck/launcher/agent/AgentBridgeProvider.kt
@@ -0,0 +1,166 @@
+package com.droiddeck.launcher.agent
+
+import android.Manifest
+import android.content.ContentProvider
+import android.content.ContentValues
+import android.content.Context
+import android.content.pm.PackageManager
+import android.database.Cursor
+import android.net.Uri
+import android.os.Binder
+import android.os.Bundle
+import android.os.Process
+import com.droiddeck.launcher.BuildConfig
+import com.droiddeck.launcher.runtime.LinuxRuntime
+import com.droiddeck.launcher.runtime.LinuxRuntimeInstaller
+import com.droiddeck.launcher.session.SessionArtifacts
+import com.droiddeck.launcher.session.SessionEvents
+import com.droiddeck.launcher.session.SessionPhase
+import com.droiddeck.launcher.session.SessionPaths
+import com.droiddeck.launcher.session.SessionService
+import com.droiddeck.launcher.session.SessionState
+import org.json.JSONArray
+import org.json.JSONObject
+import java.io.File
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+
+/** A shell-only control surface, installed only by debug builds. */
+class AgentBridgeProvider : ContentProvider() {
+ override fun onCreate(): Boolean {
+ val appContext = context?.applicationContext ?: return false
+ Thread({
+ try {
+ SessionArtifacts.finishAbandoned(appContext)
+ } finally {
+ recoveryComplete.countDown()
+ }
+ }, "agent-recover-artifacts").start()
+ return true
+ }
+
+ override fun call(method: String, arg: String?, extras: Bundle?): Bundle {
+ val context = requireNotNull(context)
+ if (Binder.getCallingUid() != Process.myUid() &&
+ context.checkCallingPermission(Manifest.permission.DUMP) != PackageManager.PERMISSION_GRANTED
+ ) {
+ throw SecurityException("android.permission.DUMP is required")
+ }
+
+ val response = try {
+ when (method) {
+ "state" -> state(context).put("ok", true)
+ "start" -> error("USE_DROIDDECKCTL", "Start sessions with tools/droiddeckctl so Android launches a visible Activity")
+ "stop" -> stop(context)
+ "resume" -> resume(context)
+ else -> error("UNKNOWN_COMMAND", "Unknown agent command '$method'")
+ }
+ } catch (e: Exception) {
+ error("COMMAND_FAILED", e.message ?: e.javaClass.simpleName)
+ }
+ return Bundle().apply { putString(RESULT_JSON, response.toString()) }
+ }
+
+ private fun state(context: Context): JSONObject {
+ val runtimeVersion = LinuxRuntimeInstaller.installedVersion(context)
+ val dir = SessionState.logDirectory ?: SessionState.logFile?.parentFile ?: latestSessionDirectory()
+ val session = JSONObject()
+ .put("id", SessionState.sessionId ?: dir?.name ?: JSONObject.NULL)
+ .put("phase", SessionState.phase.name)
+ .put("running", SessionState.running)
+ .put("mode", SessionState.mode)
+ .put("program", SessionState.program ?: JSONObject.NULL)
+ .put("steamUi", SessionState.steamUi ?: JSONObject.NULL)
+ .put("steamUrl", SessionState.steamUrl ?: JSONObject.NULL)
+ .put("suspended", SessionState.suspended)
+ .put("firstFrame", SessionState.firstFrameSeen)
+ .put("output", JSONArray().put(SessionState.outputSize.first).put(SessionState.outputSize.second))
+ .put("refreshHz", SessionState.refreshHz.toDouble())
+ .put("lastTransitionAt", SessionState.lastTransitionAt)
+ .put("guestPid", SessionState.guestPid.takeIf { it > 1 } ?: JSONObject.NULL)
+ .put("installing", SessionState.installing ?: JSONObject.NULL)
+ .put("logDir", dir?.absolutePath ?: JSONObject.NULL)
+ .put("eventsFile", dir?.let { File(it, "events.jsonl").absolutePath } ?: JSONObject.NULL)
+ .put("artifactsAvailable", dir?.isDirectory ?: false)
+ .put("artifactsComplete", dir?.let { File(it, ".complete").isFile } ?: false)
+
+ val failure = if (SessionState.failureCode == null) JSONObject.NULL else JSONObject()
+ .put("code", SessionState.failureCode)
+ .put("message", SessionState.failureMessage ?: JSONObject.NULL)
+ .put("status", SessionState.failureStatus ?: JSONObject.NULL)
+ session.put("failure", failure)
+
+ return JSONObject()
+ .put("schema", 1)
+ .put("build", BuildConfig.BUILD_LABEL)
+ .put("appVersion", context.packageManager.getPackageInfo(context.packageName, 0).versionName)
+ .put("runtime", JSONObject()
+ .put("installed", LinuxRuntime.isInstalled(context))
+ .put("version", runtimeVersion ?: JSONObject.NULL))
+ .put("session", session)
+ }
+
+ private fun latestSessionDirectory(): File? {
+ val parent = LinuxRuntime.debugLogDir()
+ return parent.listFiles { file -> file.isDirectory && file.name.startsWith("session-") }
+ ?.maxWithOrNull(compareBy { it.lastModified() }.thenBy { it.name })
+ }
+
+ private fun stop(context: Context): JSONObject {
+ if (SessionState.running) {
+ SessionService.stop(context)
+ return JSONObject().put("ok", true).put("command", "stop")
+ }
+ if (SessionState.phase == SessionPhase.STOPPING) {
+ return JSONObject().put("ok", true).put("command", "stop")
+ }
+ if (SessionState.phase in STARTING_PHASES) {
+ SessionState.stopRequested = true
+ SessionEvents.transition(SessionPhase.STOPPING, "session.stop_requested")
+ return JSONObject().put("ok", true).put("command", "stop")
+ }
+ return error("NO_ACTIVE_SESSION", "There is no session to stop")
+ }
+
+ private fun resume(context: Context): JSONObject {
+ if (!SessionState.running || !SessionState.suspended) {
+ return error("SESSION_NOT_SUSPENDED", "There is no suspended session to resume")
+ }
+ SessionService.resume(context)
+ return JSONObject().put("ok", true).put("command", "resume")
+ }
+
+ private fun error(code: String, message: String) = JSONObject()
+ .put("ok", false)
+ .put("error", JSONObject().put("code", code).put("message", message))
+
+ override fun query(uri: Uri, projection: Array?, selection: String?, selectionArgs: Array?, sortOrder: String?): Cursor? = null
+ override fun getType(uri: Uri): String? = null
+ override fun insert(uri: Uri, values: ContentValues?): Uri? = null
+ override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int = 0
+ override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array?): Int = 0
+
+ companion object {
+ const val RESULT_JSON = "json"
+ private val recoveryComplete = CountDownLatch(1)
+
+ fun awaitRecovery() {
+ try {
+ if (!recoveryComplete.await(90, TimeUnit.SECONDS)) {
+ throw IllegalStateException("Session artifact recovery did not finish")
+ }
+ } catch (e: InterruptedException) {
+ Thread.currentThread().interrupt()
+ throw IllegalStateException("Interrupted while recovering session artifacts", e)
+ }
+ }
+
+ private val STARTING_PHASES = setOf(
+ SessionPhase.PREPARING,
+ SessionPhase.INSTALLING_RUNTIME,
+ SessionPhase.STARTING_COMPOSITOR,
+ SessionPhase.STARTING_GUEST,
+ SessionPhase.STARTING_STEAM,
+ )
+ }
+}
diff --git a/app/src/debug/java/com/droiddeck/launcher/agent/AgentStartActivity.kt b/app/src/debug/java/com/droiddeck/launcher/agent/AgentStartActivity.kt
new file mode 100644
index 000000000..9e35ca46c
--- /dev/null
+++ b/app/src/debug/java/com/droiddeck/launcher/agent/AgentStartActivity.kt
@@ -0,0 +1,99 @@
+package com.droiddeck.launcher.agent
+
+import android.app.Activity
+import android.content.Intent
+import android.os.Bundle
+import android.util.Base64
+import android.util.Log
+import com.droiddeck.launcher.SessionActivity
+import com.droiddeck.launcher.session.SessionService
+import org.json.JSONArray
+import org.json.JSONObject
+
+/** Shell-launched trampoline: Android permits the shell to start this protected debug Activity. */
+class AgentStartActivity : Activity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ val request = decodeRequest(intent.getStringExtra(EXTRA_REQUEST))
+ if (request == null) {
+ finish()
+ return
+ }
+
+ Thread({
+ try {
+ AgentBridgeProvider.awaitRecovery()
+ runOnUiThread {
+ if (!isFinishing) startSession(request)
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "waiting for agent recovery", e)
+ runOnUiThread { finish() }
+ }
+ }, "agent-start-recovery").start()
+ }
+
+ private fun decodeRequest(encoded: String?): JSONObject? {
+ if (encoded.isNullOrBlank()) {
+ Log.e(TAG, "missing agent start request")
+ return null
+ }
+ return try {
+ JSONObject(String(Base64.decode(encoded, Base64.DEFAULT), Charsets.UTF_8))
+ } catch (e: Exception) {
+ Log.e(TAG, "invalid agent start request", e)
+ null
+ }
+ }
+
+ private fun startSession(request: JSONObject) {
+ val requestedMode = request.optString("mode")
+ val sessionMode = when (requestedMode) {
+ "steam" -> SessionService.MODE_STEAM
+ "desktop" -> SessionService.MODE_DESKTOP
+ "run" -> SessionService.MODE_RUN
+ else -> {
+ Log.e(TAG, "invalid agent session mode: $requestedMode")
+ finish()
+ return
+ }
+ }
+
+ val program = if (sessionMode == SessionService.MODE_RUN) {
+ request.optString("program").takeIf { it.isNotBlank() }
+ } else {
+ null
+ }
+ if (sessionMode == SessionService.MODE_RUN && program == null) {
+ Log.e(TAG, "run mode requires a program path")
+ finish()
+ return
+ }
+
+ val sessionIntent = Intent(this, SessionActivity::class.java).apply {
+ action = SessionService.ACTION_AGENT_START
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
+ putExtra(SessionService.EXTRA_MODE, sessionMode)
+ if (program != null) {
+ val args = request.optJSONArray("programArgs") ?: JSONArray()
+ putExtra(SessionService.EXTRA_PROGRAM, program)
+ putExtra(SessionService.EXTRA_PROGRAM_ARGS, Array(args.length()) { index -> args.optString(index) })
+ }
+ if (sessionMode == SessionService.MODE_STEAM) {
+ request.optString("steamUi").takeIf { it == "desktop" }?.let {
+ putExtra(SessionService.EXTRA_STEAM_UI, it)
+ }
+ request.optString("steamUrl").takeIf { it.startsWith("steam://") }?.let {
+ putExtra(SessionService.EXTRA_STEAM_URL, it)
+ }
+ }
+ }
+ startActivity(sessionIntent)
+ finish()
+ }
+
+ companion object {
+ const val EXTRA_REQUEST = "request"
+ private const val TAG = "AgentStartActivity"
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt
index f8c5ea3fc..239e3d5f5 100644
--- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt
+++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt
@@ -48,6 +48,9 @@ import com.droiddeck.launcher.session.PerfHints
import com.droiddeck.launcher.session.PerfHud
import com.droiddeck.launcher.session.PerfMode
import com.droiddeck.launcher.session.SessionPrefs
+import com.droiddeck.launcher.session.SessionEvents
+import com.droiddeck.launcher.session.SessionArtifacts
+import com.droiddeck.launcher.session.SessionPhase
import com.droiddeck.launcher.session.SessionPaths
import com.droiddeck.launcher.wayland.HdrSupport
import com.droiddeck.launcher.session.SessionService
@@ -160,6 +163,16 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
+ if (!SessionState.running && SessionState.phase in setOf(SessionPhase.IDLE, SessionPhase.FAILED)) {
+ SessionEvents.begin(this, intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM)
+ }
+ SessionState.program = intent.getStringExtra(SessionService.EXTRA_PROGRAM)
+ SessionState.programArgs = intent.getStringArrayExtra(SessionService.EXTRA_PROGRAM_ARGS)?.toList().orEmpty()
+ SessionState.steamUi = intent.getStringExtra(SessionService.EXTRA_STEAM_UI)
+ SessionState.steamUrl = intent.getStringExtra(SessionService.EXTRA_STEAM_URL)
+ if (intent.action == SessionService.ACTION_AGENT_START) {
+ SessionEvents.record("agent.start_requested", mapOf("mode" to SessionState.mode))
+ }
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
// Game-tier power policy for the whole session: a sustained clock floor, the panel's
// fastest mode, and the OS told it is in gameplay. Logged so a slow device says why.
@@ -301,12 +314,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
updateOnScreenControls()
WaylandCompositor.setFirstFrameListener {
- SessionState.firstFrameSeen = true
+ SessionEvents.firstFrame()
runOnUiThread {
loading.visible = false
hud.start()
}
}
+ SessionEvents.markReadyIfPossible()
SessionState.endListener = endListener
// A single Back opens the session menu; two quick presses/swipes send the Steam QAM chord.
// The single action waits out the double-press window so the two actions stay distinct.
@@ -393,14 +407,46 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
* starts when they are in. Nothing else changes.
*/
private fun installThenStart(runtime: Boolean, desktop: Boolean) {
+ if (SessionState.stopRequested) return
installingRuntime = true
+ SessionState.installing = if (runtime) "runtime" else "desktop"
+ SessionEvents.transition(
+ SessionPhase.INSTALLING_RUNTIME,
+ if (runtime) "runtime.installing" else "desktop.installing",
+ mapOf("component" to SessionState.installing),
+ )
loading.step = if (runtime) "downloading the Linux runtime" else "downloading the desktop"
loading.percent = -1
Thread({
- val problem = (if (runtime) installRuntime() else null) ?: (if (desktop) installDesktop() else null)
+ var failedComponent: String? = null
+ var problem: String? = null
+ if (runtime) {
+ problem = installRuntime()
+ if (problem == null) SessionEvents.record("runtime.ready") else failedComponent = "runtime"
+ }
+ if (problem == null && desktop && !SessionState.stopRequested) {
+ SessionState.installing = "desktop"
+ SessionEvents.transition(SessionPhase.INSTALLING_RUNTIME, "desktop.installing", mapOf("component" to "desktop"))
+ problem = installDesktop()
+ if (problem == null) SessionEvents.record("desktop.ready") else failedComponent = "desktop"
+ }
uiHandler.post {
installingRuntime = false
- if (problem != null) { loading.showEnded(problem); return@post }
+ SessionState.installing = null
+ if (SessionState.stopRequested) {
+ SessionState.stopRequested = false
+ SessionEvents.transition(SessionPhase.IDLE, "session.cancelled")
+ collectStartArtifacts("session start cancelled")
+ finish()
+ return@post
+ }
+ if (problem != null) {
+ val code = if (failedComponent == "runtime") "RUNTIME_INSTALL_FAILED" else "DESKTOP_INSTALL_FAILED"
+ SessionEvents.fail(code, problem)
+ collectStartArtifacts("session start failed")
+ loading.showEnded(problem)
+ return@post
+ }
loading.percent = -1
loading.step = "Starting the session…"
// The surface may have come and gone while the download ran; start on the live one.
@@ -409,6 +455,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}, "runtime-install").start()
}
+ private fun collectStartArtifacts(reason: String) {
+ val dir = SessionPaths.take() ?: return
+ val appContext = applicationContext
+ Thread({
+ SessionArtifacts.collect(appContext, dir, reason)
+ SessionPaths.release(appContext, dir)
+ }, "session-failure-artifacts").start()
+ }
+
/** Reports one package's download on the loading screen: " · 332 of 791 MB", checking, unpacking. */
private fun progressFor(what: String, mb: Long) = com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.ProgressListener { stage, p ->
uiHandler.post {
@@ -441,7 +496,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
override fun surfaceCreated(holder: SurfaceHolder) {
- if (installingRuntime) return
+ if (installingRuntime || SessionState.stopRequested) return
+ if (!SessionState.running) {
+ SessionEvents.transition(SessionPhase.STARTING_COMPOSITOR, "compositor.starting")
+ }
val runtimeDir = File(filesDir, ".wayland-rt").apply { mkdirs() }
// The compositor hands this keymap to wl_keyboard clients, which is how the guest reads
// the evdev codes we inject.
@@ -504,6 +562,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
driverId?.let { turnip.driverPath(it) }, driverId?.let { turnip.libraryName(it) },
applicationInfo.nativeLibraryDir, size.first, size.second, refreshHz(),
)
+ if (!SessionState.running) SessionEvents.record("compositor.started")
// ADPF: the compositor thread's frame intervals go to the power HAL against the panel's
// period, so a long frame raises CPU clocks now rather than after the load averages up.
PerfHints.arm(this, refreshHz())
@@ -513,6 +572,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
// start nothing, and leave the loading panel counting up over a dead session.
if (!SessionState.running) {
CompositorHost.newSession()
+ SessionEvents.transition(SessionPhase.STARTING_GUEST, "guest.starting")
SessionService.start(
this, intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM,
intent.getStringExtra(SessionService.EXTRA_PROGRAM),
@@ -613,6 +673,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
val poll = object : Runnable {
override fun run() {
if (!watching) return
+ if (SessionState.stopRequested && !SessionState.running && !installingRuntime) {
+ SessionState.stopRequested = false
+ SessionEvents.transition(SessionPhase.IDLE, "session.cancelled")
+ collectStartArtifacts("session start cancelled")
+ finish()
+ return
+ }
if (loading.visible && !loading.ended) {
if (!installingRuntime) loading.update(this@SessionActivity, SessionState.logFile)
if (ticks++ % 2 == 0) loading.tick()
@@ -1125,6 +1192,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun onNewIntent(intent: android.content.Intent) {
super.onNewIntent(intent)
+ if (intent.action == SessionService.ACTION_AGENT_START) {
+ if (SessionState.running || SessionState.phase !in setOf(SessionPhase.IDLE, SessionPhase.FAILED)) {
+ Log.w(TAG, "ignoring agent start while session phase is ${SessionState.phase}")
+ return
+ }
+ setIntent(intent)
+ recreate()
+ return
+ }
setIntent(intent)
if (intent.action == SessionService.ACTION_HOME_GUIDE) {
handleHomeGuideIntent(intent)
diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionArtifacts.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionArtifacts.kt
index 9dee67289..ddc65941a 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/SessionArtifacts.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/SessionArtifacts.kt
@@ -43,6 +43,7 @@ object SessionArtifacts {
copySteamLogs(context, dir)
// A session the system killed leaves its trace here and nowhere else.
SessionLogCapture.dumpCrashBuffer(File(dir, "crash.log"))
+ SessionEvents.record("session.artifacts_collected", mapOf("reason" to reason), dir)
File(dir, COMPLETE_MARKER).writeText("collected: $reason at ${now()}\n")
} catch (e: Exception) {
Log.w(TAG, "collecting session artifacts", e)
@@ -72,6 +73,7 @@ object SessionArtifacts {
* older folder would be handed logs that are not its own. Runs on a worker thread at app
* start; nothing here touches the session that is about to begin.
*/
+ @Synchronized
fun finishAbandoned(context: Context) {
val parent = LinuxRuntime.debugLogDir()
val abandoned = parent.listFiles { f ->
@@ -97,6 +99,7 @@ object SessionArtifacts {
copySteamLogs(context, dir)
}
SessionLogCapture.dumpCrashBuffer(File(dir, "crash.log"))
+ SessionEvents.record("session.artifacts_recovered", mapOf("newest" to newest), dir)
File(dir, COMPLETE_MARKER).writeText("collected: late, at next app start, ${now()}\n")
Log.i(TAG, "finished the abandoned session folder $dir")
} catch (e: Exception) {
diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionEvents.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionEvents.kt
new file mode 100644
index 000000000..cd7955a57
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/session/SessionEvents.kt
@@ -0,0 +1,117 @@
+package com.droiddeck.launcher.session
+
+import android.content.Context
+import android.util.Log
+import org.json.JSONObject
+import java.io.File
+import java.io.FileOutputStream
+
+/** Writes the machine-readable lifecycle stream beside the existing session artifacts. */
+object SessionEvents {
+ private const val TAG = "SessionEvents"
+ private val lock = Any()
+
+ fun begin(context: Context, mode: String) {
+ // A runtime-install failure can leave the folder claimed before the service ever starts.
+ // Keep that folder, but give the next attempt its own session directory.
+ SessionPaths.take()
+ val dir = SessionPaths.beginOrCurrent(context)
+ synchronized(SessionState) {
+ SessionState.phase = SessionPhase.PREPARING
+ SessionState.mode = mode
+ SessionState.sessionId = dir.name
+ SessionState.lastTransitionAt = System.currentTimeMillis()
+ SessionState.failureCode = null
+ SessionState.failureMessage = null
+ SessionState.failureStatus = null
+ SessionState.logDirectory = dir
+ SessionState.logFile = File(dir, "session.log")
+ SessionState.eventsFile = File(dir, "events.jsonl")
+ SessionState.guestPid = -1
+ SessionState.installing = null
+ SessionState.stopRequested = false
+ SessionState.program = null
+ SessionState.programArgs = emptyList()
+ SessionState.steamUi = null
+ SessionState.steamUrl = null
+ SessionState.firstFrameSeen = false
+ SessionState.suspended = false
+ }
+ record("session.created", mapOf("mode" to mode), dir)
+ }
+
+ fun transition(
+ phase: SessionPhase,
+ event: String,
+ fields: Map = emptyMap(),
+ ) {
+ synchronized(SessionState) {
+ if (SessionState.phase != phase) {
+ SessionState.phase = phase
+ SessionState.lastTransitionAt = System.currentTimeMillis()
+ }
+ }
+ record(event, fields)
+ }
+
+ fun record(event: String, fields: Map = emptyMap(), directory: File? = null) {
+ val dir = directory ?: SessionState.logDirectory ?: SessionPaths.current() ?: return
+ val target = if (directory == null) SessionState.eventsFile ?: File(dir, "events.jsonl")
+ else File(dir, "events.jsonl")
+ val payload = JSONObject()
+ .put("t", System.currentTimeMillis())
+ .put("event", event)
+ .put("sessionId", dir.name)
+ fields.forEach { (key, value) -> payload.put(key, value ?: JSONObject.NULL) }
+
+ try {
+ synchronized(lock) {
+ target.parentFile?.mkdirs()
+ FileOutputStream(target, true).use { stream ->
+ stream.write((payload.toString() + "\n").toByteArray(Charsets.UTF_8))
+ }
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "writing ${target.name}", e)
+ }
+ }
+
+ fun fail(code: String, message: String, status: Int? = null) {
+ synchronized(SessionState) {
+ SessionState.phase = SessionPhase.FAILED
+ SessionState.lastTransitionAt = System.currentTimeMillis()
+ SessionState.failureCode = code
+ SessionState.failureMessage = message
+ SessionState.failureStatus = status
+ }
+ record("session.failed", mapOf("code" to code, "message" to message, "status" to status))
+ }
+
+ fun firstFrame() {
+ SessionState.firstFrameSeen = true
+ record("frame.first")
+ markReadyIfPossible()
+ }
+
+ fun guestStarted(pid: Int) {
+ SessionState.guestPid = pid
+ record("guest.started", mapOf("pid" to pid))
+ if (SessionState.mode == SessionService.MODE_STEAM) {
+ transition(SessionPhase.STARTING_STEAM, "steam.starting")
+ }
+ markReadyIfPossible()
+ }
+
+ fun markReadyIfPossible() {
+ if (!SessionState.running || SessionState.guestPid <= 1 || !SessionState.firstFrameSeen || SessionState.suspended) return
+ if (SessionState.phase != SessionPhase.READY) {
+ transition(SessionPhase.READY, "session.ready")
+ }
+ }
+
+ fun resumePhase(): SessionPhase = when {
+ SessionState.firstFrameSeen && SessionState.guestPid > 1 -> SessionPhase.READY
+ SessionState.mode == SessionService.MODE_STEAM && SessionState.guestPid > 1 -> SessionPhase.STARTING_STEAM
+ else -> SessionPhase.STARTING_GUEST
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPaths.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPaths.kt
index 68570c848..485bdc457 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/SessionPaths.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPaths.kt
@@ -41,7 +41,10 @@ object SessionPaths {
val stamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date())
val parent = if (SessionPrefs.logsEnabled(context)) SessionFiles.logDirectory(context)
else File(context.cacheDir, "session-logs")
- val made = File(parent, "session-$stamp")
+ val baseName = "session-$stamp"
+ var made = File(parent, baseName)
+ var suffix = 2
+ while (made.exists()) made = File(parent, "$baseName-${suffix++}")
if (!made.isDirectory && !made.mkdirs()) Log.e(TAG, "could not create $made")
dir = made
Log.i(TAG, "session logs: $made" + if (SessionPrefs.logsEnabled(context)) "" else " (logs off: discarded at the end)")
diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt
index 5fa6f3c5d..47bf4c22e 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt
@@ -155,6 +155,11 @@ class SessionService : Service() {
}
startForeground(NOTIFICATION_ID, buildNotification())
if (SessionState.running) return START_NOT_STICKY
+ if (SessionState.stopRequested) {
+ SessionState.running = true
+ stopSession(0)
+ return START_NOT_STICKY
+ }
SessionState.mode = intent?.getStringExtra(EXTRA_MODE) ?: MODE_STEAM
suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode)
activityVisible = true
@@ -168,11 +173,17 @@ class SessionService : Service() {
SessionState.programArgs = intent?.getStringArrayExtra(EXTRA_PROGRAM_ARGS)?.toList().orEmpty()
SessionState.steamUi = intent?.getStringExtra(EXTRA_STEAM_UI)
SessionState.steamUrl = intent?.getStringExtra(EXTRA_STEAM_URL)
+ SessionState.running = true
+ if (SessionState.stopRequested) {
+ stopSession(0)
+ return START_NOT_STICKY
+ }
// Another Steam client on the device signs ours out seconds after every login; the one that
// does it here runs from boot without being opened. Only the Steam session signs in.
if (SessionState.mode == MODE_STEAM) RivalClients.stopBeforeSession(this)
- SessionState.running = true
SessionState.firstFrameSeen = false
+ SessionState.guestPid = -1
+ SessionEvents.transition(SessionPhase.STARTING_GUEST, "service.started", mapOf("mode" to SessionState.mode))
// This session's number, claimed here and not when its process starts: the session it
// replaces can report its own exit in the gap between the two, and that exit must not
// be taken as this one's.
@@ -249,6 +260,10 @@ class SessionService : Service() {
val sessionDir = SessionPaths.beginOrCurrent(this)
val sessionLog = File(sessionDir, "session.log")
SessionState.logFile = sessionLog
+ SessionState.logDirectory = sessionDir
+ SessionState.sessionId = sessionDir.name
+ SessionState.eventsFile = File(sessionDir, "events.jsonl")
+ SessionEvents.record("session.logs_ready", mapOf("logDir" to sessionDir.path))
// Written first, so a session that dies in its first second still says what it ran on.
DeviceReport.write(this, File(sessionDir, "device.txt"), SessionState.mode)
NetworkReport.write(this, File(sessionDir, "network.txt"))
@@ -590,6 +605,14 @@ class SessionService : Service() {
stopSession(status ?: -1)
}, null)
Log.i(TAG, "session pid $sessionPid, log ${sessionLog.path}")
+ if (gen == sessionGen && SessionState.running && sessionPid > 1) {
+ SessionEvents.guestStarted(sessionPid)
+ } else if (gen == sessionGen && SessionState.running) {
+ SessionEvents.record("guest.start_failed", mapOf("pid" to sessionPid))
+ SessionEvents.fail("GUEST_START_FAILED", "The guest process could not be started", sessionPid)
+ stopSession(-1)
+ return
+ }
if (gen == sessionGen && SessionState.running) {
suspendController = SessionSuspendController(
sessionRoot = { sessionPid },
@@ -752,6 +775,7 @@ class SessionService : Service() {
suspendOperationPending = false
if (success) {
SessionState.suspended = true
+ SessionEvents.transition(SessionPhase.SUSPENDED, "session.suspended")
releaseLocks()
refreshNotification()
} else {
@@ -769,6 +793,7 @@ class SessionService : Service() {
suspendOperationPending = false
if (success) {
SessionState.suspended = false
+ SessionEvents.transition(SessionEvents.resumePhase(), "session.resumed")
refreshNotification()
} else {
suspendAttemptFailed = true
@@ -783,7 +808,16 @@ class SessionService : Service() {
private fun finishSessionStop(status: Int) {
mainHandler.post {
SessionState.suspended = false
+ SessionState.guestPid = -1
releaseLocks()
+ if (status == 0) {
+ SessionEvents.transition(SessionPhase.IDLE, "session.stopped", mapOf("status" to status))
+ } else {
+ val code = SessionState.failureCode ?: "GUEST_EXIT"
+ val message = SessionState.failureMessage ?: "Session guest exited with status $status"
+ val failureStatus = SessionState.failureStatus ?: status
+ SessionEvents.fail(code, message, failureStatus)
+ }
SessionState.notifyEnded(status)
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
@@ -793,6 +827,9 @@ class SessionService : Service() {
private fun stopSession(status: Int) {
if (!SessionState.running) return
SessionState.running = false
+ SessionState.stopRequested = false
+ SessionEvents.record("guest.exited", mapOf("status" to status))
+ SessionEvents.transition(SessionPhase.STOPPING, "session.stopping", mapOf("status" to status))
suspendOperationPending = false
launchWatcher?.stopWatching()
launchWatcher = null
@@ -974,6 +1011,7 @@ class SessionService : Service() {
const val ACTION_STOP = "com.droiddeck.launcher.STOP_SESSION"
const val ACTION_RESUME = "com.droiddeck.launcher.RESUME_SESSION"
const val ACTION_HOME_GUIDE = "com.droiddeck.launcher.HOME_GUIDE"
+ const val ACTION_AGENT_START = "com.droiddeck.launcher.AGENT_START"
private const val ACTION_ACTIVITY_VISIBLE = "com.droiddeck.launcher.ACTIVITY_VISIBLE"
private const val ACTION_ACTIVITY_HIDDEN = "com.droiddeck.launcher.ACTIVITY_HIDDEN"
private const val ACTION_TRACK_AUXILIARY = "com.droiddeck.launcher.TRACK_AUXILIARY"
diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt
index d2d3349c5..21227ba51 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt
@@ -11,14 +11,51 @@ import androidx.compose.runtime.setValue
* either can outlive the other.
*/
object SessionState {
+ @Volatile
+ var phase = SessionPhase.IDLE
+
+ @Volatile
+ var sessionId: String? = null
+
+ @Volatile
+ var lastTransitionAt = 0L
+
+ @Volatile
+ var failureCode: String? = null
+
+ @Volatile
+ var failureMessage: String? = null
+
+ @Volatile
+ var failureStatus: Int? = null
+
+ @Volatile
+ var logDirectory: File? = null
+
+ @Volatile
+ var eventsFile: File? = null
+
+ @Volatile
+ var guestPid = -1
+
+ @Volatile
+ var installing: String? = null
+
+ @Volatile
+ var stopRequested = false
+
@Volatile
var running = false
var suspended by mutableStateOf(false)
/** MODE_RUN: the program inside the runtime the session was started for. */
+ @Volatile
var program: String? = null
+ @Volatile
var programArgs: List = emptyList()
/** MODE_STEAM: "desktop" for the client's desktop UI, else Big Picture; and a steam:// URL to hand it. */
+ @Volatile
var steamUi: String? = null
+ @Volatile
var steamUrl: String? = null
/** A session the guest asked for (the desktop's Steam launchers): started by the activity once this one has ended. */
var relaunch: android.content.Intent? = null
@@ -57,3 +94,17 @@ object SessionState {
endListener?.invoke(status)
}
}
+
+/** Stable lifecycle values returned by the debug agent bridge. */
+enum class SessionPhase {
+ IDLE,
+ PREPARING,
+ INSTALLING_RUNTIME,
+ STARTING_COMPOSITOR,
+ STARTING_GUEST,
+ STARTING_STEAM,
+ READY,
+ SUSPENDED,
+ STOPPING,
+ FAILED,
+}
diff --git a/docs/agent-control.md b/docs/agent-control.md
new file mode 100644
index 000000000..77e4ce583
--- /dev/null
+++ b/docs/agent-control.md
@@ -0,0 +1,41 @@
+# Agent control
+
+The debug APK exposes a ContentProvider at `content://com.droiddeck.launcher.agent` and an Activity for starting sessions. Android's shell-only `DUMP` permission protects both. They are absent from release APKs.
+
+Build and install the debug APK:
+
+```sh
+./gradlew assembleDebug --console=plain
+adb install -r app/build/outputs/apk/debug/app-debug.apk
+```
+
+The provider can also be called directly for state, stop, and resume:
+
+```sh
+adb shell content call --uri content://com.droiddeck.launcher.agent --method state
+adb shell content call --uri content://com.droiddeck.launcher.agent --method stop
+```
+
+Start requests go through `tools/droiddeckctl`, which launches the protected debug Activity from the ADB shell. Calling the provider's `start` method directly returns an error because a background provider cannot reliably open the session screen.
+
+The host CLI resolves one authorized device using `ADB_SERIAL` or `ANDROID_SERIAL` when set. Otherwise it deduplicates transports that report the same device serial and asks for an explicit serial if multiple devices remain.
+
+```sh
+tools/droiddeckctl state --json
+tools/droiddeckctl start steam
+tools/droiddeckctl wait ready --timeout 90
+tools/droiddeckctl start desktop
+tools/droiddeckctl run /usr/bin/foo -- arg1 arg2
+tools/droiddeckctl stop
+tools/droiddeckctl resume
+tools/droiddeckctl logs latest ./session-artifacts
+tools/droiddeckctl screenshot ./screen.png
+```
+
+Every command writes JSON to stdout and reports its resolved ADB serial to stderr. Exit codes are 0 for success, 2 for invalid or rejected commands, 3 for ADB/device errors, 4 for session failures, 5 for timeouts, and 6 for artifact or file errors. Set `ADB` to select an adb executable (or pass `--adb`); pass `--serial` before the command to select a device directly.
+
+`start` accepts `steam` or `desktop`. Steam starts in Big Picture by default; `--ui desktop` selects the client's desktop UI, and `--url steam://...` passes a client URL. Use `--wait` to wait for `READY` as part of `start`. `run` accepts a program path and optional guest arguments.
+
+The `state` response uses schema 1 and includes the build label, runtime version, session ID and phase, mode, requested program, suspend and first-frame state, output size, guest PID, failure details, and artifact paths. Phases are `IDLE`, `PREPARING`, `INSTALLING_RUNTIME`, `STARTING_COMPOSITOR`, `STARTING_GUEST`, `STARTING_STEAM`, `READY`, `SUSPENDED`, `STOPPING`, and `FAILED`. Each session also writes `events.jsonl` alongside its existing artifacts.
+
+`tools/deploy_local.sh` uses the same device resolver. If multiple distinct devices are connected, select one with `ADB_SERIAL` or `ANDROID_SERIAL`.
diff --git a/tools/adb_device.py b/tools/adb_device.py
new file mode 100755
index 000000000..5e2990399
--- /dev/null
+++ b/tools/adb_device.py
@@ -0,0 +1,105 @@
+#!/usr/bin/env python3
+"""Resolve one authorized Android device, collapsing transports for the same device."""
+
+from __future__ import annotations
+
+import argparse
+import os
+import re
+import subprocess
+import sys
+
+
+class DeviceResolutionError(RuntimeError):
+ pass
+
+
+def _run(adb: str, *args: str, timeout: int = 15) -> subprocess.CompletedProcess[str]:
+ try:
+ return subprocess.run(
+ [adb, *args], capture_output=True, text=True, check=False, timeout=timeout
+ )
+ except (OSError, subprocess.TimeoutExpired) as exc:
+ raise DeviceResolutionError(str(exc)) from exc
+
+
+def _device_list(adb: str) -> list[str]:
+ result = _run(adb, "devices", "-l")
+ if result.returncode != 0:
+ raise DeviceResolutionError(result.stderr.strip() or "adb devices failed")
+ devices = []
+ for line in result.stdout.splitlines()[1:]:
+ fields = line.split()
+ if len(fields) >= 2 and fields[1] == "device":
+ devices.append(fields[0])
+ return devices
+
+
+def _identity(adb: str, serial: str) -> str | None:
+ for prop in ("ro.serialno", "ro.boot.serialno"):
+ result = _run(adb, "-s", serial, "shell", "getprop", prop)
+ value = result.stdout.strip()
+ if result.returncode == 0 and value and value.lower() not in {"unknown", "null", "0"}:
+ return value
+ return None
+
+
+def _rank(serial: str) -> tuple[int, str]:
+ # Prefer a stable mDNS transport over a network address with an ephemeral port.
+ if "_adb-tls-connect._tcp" in serial:
+ return (0, serial)
+ if not re.search(r":\d+$", serial):
+ return (1, serial)
+ return (2, serial)
+
+
+def resolve_serial(adb: str, explicit: str | None = None, *, log: bool = False) -> str:
+ selected = explicit or os.environ.get("ADB_SERIAL") or os.environ.get("ANDROID_SERIAL")
+ if selected:
+ result = _run(adb, "-s", selected, "get-state")
+ if result.returncode != 0 or result.stdout.strip() != "device":
+ raise DeviceResolutionError(f"ADB serial '{selected}' is not an authorized device")
+ serial = selected
+ else:
+ devices = _device_list(adb)
+ if not devices:
+ raise DeviceResolutionError("No authorized Android device found; set ADB_SERIAL or ANDROID_SERIAL")
+ identities = {device: _identity(adb, device) for device in devices}
+ groups: dict[str, list[str]] = {}
+ for device in devices:
+ identity = identities[device]
+ key = f"device:{identity}" if identity else f"transport:{device}"
+ groups.setdefault(key, []).append(device)
+ if len(groups) != 1:
+ detail = ", ".join(
+ f"{device}" + (f" (serial {identities[device]})" if identities[device] else "")
+ for device in devices
+ )
+ raise DeviceResolutionError(
+ f"Multiple Android devices found ({detail}); set ADB_SERIAL or ANDROID_SERIAL"
+ )
+ aliases = next(iter(groups.values()))
+ serial = min(aliases, key=_rank)
+ if log and len(aliases) > 1:
+ print(f"Collapsed {len(aliases)} ADB transports for one device.", file=sys.stderr)
+
+ if log:
+ print(f"Resolved ADB serial: {serial}", file=sys.stderr)
+ return serial
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--adb", default=os.environ.get("ADB", "adb"))
+ parser.add_argument("--serial", help="Explicit ADB serial; defaults to ADB_SERIAL or ANDROID_SERIAL")
+ args = parser.parse_args()
+ try:
+ print(resolve_serial(args.adb, args.serial, log=True))
+ return 0
+ except DeviceResolutionError as exc:
+ print(f"adb-device: {exc}", file=sys.stderr)
+ return 2
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/deploy_local.sh b/tools/deploy_local.sh
index 9ee303db0..94b3692d4 100755
--- a/tools/deploy_local.sh
+++ b/tools/deploy_local.sh
@@ -15,22 +15,7 @@ if [[ ! -f "${apk}" ]]; then
exit 1
fi
-if [[ -n "${ADB_SERIAL:-}" ]]; then
- serial=${ADB_SERIAL}
-else
- devices=$("${adb_bin}" devices | awk '$2 == "device" {print $1}')
- device_count=$(printf '%s\n' "${devices}" | sed '/^$/d' | wc -l | tr -d ' ')
- if [[ "${device_count}" == 0 ]]; then
- echo "No authorized Android device found. Connect one or set ADB_SERIAL." >&2
- exit 2
- fi
- if [[ "${device_count}" != 1 ]]; then
- echo "Multiple Android devices found; set ADB_SERIAL explicitly." >&2
- printf '%s\n' "${devices}" >&2
- exit 2
- fi
- serial=${devices}
-fi
+serial=$(python3 "${repo_root}/tools/adb_device.py" --adb "${adb_bin}")
"${adb_bin}" -s "${serial}" install -r --no-incremental "${apk}"
installed_path=$("${adb_bin}" -s "${serial}" shell pm path com.droiddeck.launcher | tr -d '\r')
diff --git a/tools/droiddeckctl b/tools/droiddeckctl
new file mode 100755
index 000000000..b73c5ac29
--- /dev/null
+++ b/tools/droiddeckctl
@@ -0,0 +1,351 @@
+#!/usr/bin/env python3
+"""Control a debug DroidDeck build over ADB."""
+
+from __future__ import annotations
+
+import argparse
+import base64
+import json
+import os
+import shutil
+import subprocess
+import sys
+import time
+from pathlib import Path
+
+from adb_device import DeviceResolutionError, resolve_serial
+
+
+AUTHORITY = "content://com.droiddeck.launcher.agent"
+
+
+class ControlError(RuntimeError):
+ def __init__(self, message: str, code: str = "COMMAND_FAILED") -> None:
+ super().__init__(message)
+ self.code = code
+
+
+def adb_path(configured: str | None) -> str:
+ if configured:
+ return configured
+ environment_adb = os.environ.get("ADB")
+ if environment_adb:
+ return environment_adb
+ found = shutil.which("adb")
+ if found:
+ return found
+ sdk = os.environ.get("ANDROID_HOME") or os.environ.get("ANDROID_SDK_ROOT")
+ if sdk:
+ candidate = Path(sdk) / "platform-tools" / "adb"
+ if candidate.is_file():
+ return str(candidate)
+ home_dir = Path.home()
+ candidate = home_dir / "Library" / "Android" / "sdk" / "platform-tools" / "adb"
+ if candidate.is_file():
+ return str(candidate)
+ raise ControlError("adb not found; set ADB, ANDROID_HOME, or ANDROID_SDK_ROOT", "ADB_NOT_FOUND")
+
+
+def run_adb(adb: str, serial: str, *args: str, timeout: int = 30) -> subprocess.CompletedProcess[bytes]:
+ try:
+ return subprocess.run(
+ [adb, "-s", serial, *args], capture_output=True, check=False, timeout=timeout
+ )
+ except (OSError, subprocess.TimeoutExpired) as exc:
+ raise ControlError(str(exc), "ADB_FAILED") from exc
+
+
+def _content_result(output: str) -> dict:
+ marker = "json="
+ start = output.find(marker)
+ if start < 0:
+ detail = output.strip() or "The debug agent provider returned no result"
+ raise ControlError(detail, "INVALID_PROVIDER_RESPONSE")
+ raw = output[start + len(marker):].lstrip()
+ try:
+ response, _ = json.JSONDecoder().raw_decode(raw)
+ if not isinstance(response, dict):
+ raise ValueError("expected an object")
+ return response
+ except (json.JSONDecodeError, ValueError) as exc:
+ raise ControlError("Could not decode the agent provider response", "INVALID_PROVIDER_RESPONSE") from exc
+
+
+def provider_call(adb: str, serial: str, method: str, arg: str | None = None, request: dict | None = None) -> dict:
+ command = ["shell", "content", "call", "--uri", AUTHORITY, "--method", method]
+ if arg is not None:
+ command.extend(["--arg", arg])
+ if request is not None:
+ encoded = base64.b64encode(json.dumps(request, separators=(",", ":")).encode()).decode()
+ command.extend(["--extra", f"request:s:{encoded}"])
+ result = run_adb(adb, serial, *command)
+ output = (result.stdout + result.stderr).decode(errors="replace")
+ response = _content_result(output)
+ if result.returncode != 0 and response.get("ok") is not False:
+ raise ControlError(output.strip() or "ADB content call failed", "ADB_FAILED")
+ return response
+
+
+def start_agent_activity(adb: str, serial: str, request: dict) -> None:
+ encoded = base64.b64encode(json.dumps(request, separators=(",", ":")).encode()).decode()
+ result = run_adb(
+ adb,
+ serial,
+ "shell",
+ "am",
+ "start",
+ "-W",
+ "-n",
+ "com.droiddeck.launcher/.agent.AgentStartActivity",
+ "--es",
+ "request",
+ encoded,
+ timeout=90,
+ )
+ output = (result.stdout + result.stderr).decode(errors="replace")
+ if result.returncode != 0 or "status: ok" not in output.lower():
+ raise ControlError(output.strip() or "The agent start Activity did not launch", "ACTIVITY_START_FAILED")
+
+
+def confirm_start(adb: str, serial: str, request: dict, previous: dict) -> dict:
+ deadline = time.monotonic() + 95
+ expected_mode = {"steam": "steam", "desktop": "lxqt", "run": "run"}[request["mode"]]
+ while True:
+ state = get_state(adb, serial)
+ session = state.get("session", {})
+ phase = session.get("phase")
+ if session.get("mode") == expected_mode and session.get("id") != previous.get("id"):
+ return state
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ raise ControlError(
+ f"The agent Activity launched but no new session appeared; phase is {phase}",
+ "ACTIVITY_START_FAILED",
+ )
+ time.sleep(min(0.25, remaining))
+
+
+def get_state(adb: str, serial: str) -> dict:
+ response = provider_call(adb, serial, "state")
+ if response.get("ok") is False:
+ error = response.get("error", {})
+ raise ControlError(error.get("message", "state failed"), error.get("code", "STATE_FAILED"))
+ return response
+
+
+def wait_for_phase(adb: str, serial: str, wanted: str, timeout: float) -> dict:
+ deadline = time.monotonic() + timeout
+ last_state: dict | None = None
+ while True:
+ last_state = get_state(adb, serial)
+ session = last_state.get("session", {})
+ phase = session.get("phase")
+ if phase == wanted:
+ return last_state
+ if phase == "FAILED" and wanted != "FAILED":
+ failure = session.get("failure") or {}
+ raise ControlError(
+ f"Session failed ({failure.get('code', 'FAILED')}): {failure.get('message', 'unknown error')}",
+ failure.get("code", "SESSION_FAILED"),
+ )
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ raise ControlError(
+ f"Timed out waiting for {wanted}; last phase was {phase}", "TIMEOUT"
+ )
+ time.sleep(min(1.0, remaining))
+
+
+def wait_for_artifacts(adb: str, serial: str, state: dict, timeout: float) -> dict:
+ deadline = time.monotonic() + timeout
+ while True:
+ session = state.get("session", {})
+ if session.get("artifactsComplete") or not session.get("artifactsAvailable"):
+ return state
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ raise ControlError("Session stopped, but artifact collection did not complete", "ARTIFACT_TIMEOUT")
+ time.sleep(min(1.0, remaining))
+ state = get_state(adb, serial)
+
+
+def add_serial(response: dict, serial: str) -> dict:
+ response["deviceSerial"] = serial
+ return response
+
+
+def execute(args: argparse.Namespace, adb: str, serial: str) -> dict:
+ if args.command == "state":
+ return add_serial(get_state(adb, serial), serial)
+
+ if args.command == "start":
+ current = get_state(adb, serial)
+ if current["session"].get("phase") not in {"IDLE", "FAILED"} or current["session"].get("running"):
+ raise ControlError("A session is already starting or running", "SESSION_ACTIVE")
+ request = {"mode": args.mode}
+ if args.mode == "steam":
+ if args.ui == "desktop":
+ request["steamUi"] = "desktop"
+ if args.url:
+ if not args.url.startswith("steam://"):
+ raise ControlError("Steam URL must start with steam://", "INVALID_STEAM_URL")
+ request["steamUrl"] = args.url
+ start_agent_activity(adb, serial, request)
+ started = confirm_start(adb, serial, request, current["session"])
+ if args.wait:
+ return add_serial(wait_for_phase(adb, serial, "READY", args.timeout), serial)
+ session = started["session"]
+ return add_serial({
+ "ok": True,
+ "command": "start",
+ "mode": args.mode,
+ "sessionId": session.get("id"),
+ "phase": session.get("phase"),
+ }, serial)
+
+ if args.command == "run":
+ current = get_state(adb, serial)
+ if current["session"].get("phase") not in {"IDLE", "FAILED"} or current["session"].get("running"):
+ raise ControlError("A session is already starting or running", "SESSION_ACTIVE")
+ program_args = args.arguments[1:] if args.arguments[:1] == ["--"] else args.arguments
+ request = {"mode": "run", "program": args.program, "programArgs": program_args}
+ start_agent_activity(adb, serial, request)
+ started = confirm_start(adb, serial, request, current["session"])
+ session = started["session"]
+ return add_serial({
+ "ok": True,
+ "command": "start",
+ "mode": "run",
+ "sessionId": session.get("id"),
+ "phase": session.get("phase"),
+ }, serial)
+
+ if args.command == "wait":
+ phases = {"ready": "READY", "idle": "IDLE", "failed": "FAILED"}
+ return add_serial(wait_for_phase(adb, serial, phases[args.phase], args.timeout), serial)
+
+ if args.command == "stop":
+ current = get_state(adb, serial)
+ if current["session"].get("phase") in {"IDLE", "FAILED"}:
+ return add_serial(wait_for_artifacts(adb, serial, current, args.timeout), serial)
+ response = provider_call(adb, serial, "stop")
+ if response.get("ok") is False:
+ error = response.get("error", {})
+ raise ControlError(error.get("message", "stop failed"), error.get("code", "STOP_FAILED"))
+ state = wait_for_phase(adb, serial, "IDLE", args.timeout)
+ return add_serial(wait_for_artifacts(adb, serial, state, args.timeout), serial)
+
+ if args.command == "resume":
+ response = provider_call(adb, serial, "resume")
+ if response.get("ok") is False:
+ error = response.get("error", {})
+ raise ControlError(error.get("message", "resume failed"), error.get("code", "RESUME_FAILED"))
+ return add_serial(response, serial)
+
+ if args.command == "logs":
+ state = get_state(adb, serial)
+ session = state.get("session", {})
+ if not session.get("running") and session.get("artifactsAvailable") and not session.get("artifactsComplete"):
+ state = wait_for_artifacts(adb, serial, state, args.timeout)
+ session = state.get("session", {})
+ remote_dir = session.get("logDir")
+ if not remote_dir or not session.get("id") or not session.get("artifactsAvailable"):
+ raise ControlError("No session artifact directory is available", "NO_SESSION_LOGS")
+ destination = Path(args.destination or session["id"]).expanduser().resolve()
+ result = run_adb(adb, serial, "pull", remote_dir, str(destination), timeout=120)
+ if result.returncode != 0:
+ detail = (result.stderr or result.stdout).decode(errors="replace").strip()
+ raise ControlError(detail or "adb pull failed", "LOG_PULL_FAILED")
+ return add_serial({"ok": True, "command": "logs", "sessionId": session["id"], "path": str(destination)}, serial)
+
+ if args.command == "screenshot":
+ destination = Path(args.path).expanduser().resolve()
+ result = run_adb(adb, serial, "exec-out", "screencap", "-p", timeout=30)
+ if result.returncode != 0:
+ detail = result.stderr.decode(errors="replace").strip()
+ raise ControlError(detail or "screencap failed", "SCREENSHOT_FAILED")
+ try:
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ destination.write_bytes(result.stdout)
+ except OSError as exc:
+ raise ControlError(str(exc), "FILE_IO") from exc
+ return add_serial({"ok": True, "command": "screenshot", "path": str(destination)}, serial)
+
+ raise ControlError(f"Unknown command: {args.command}", "UNKNOWN_COMMAND")
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--adb", help="adb executable (defaults to ADB, PATH, or the Android SDK)")
+ parser.add_argument("--serial", help="ADB serial; defaults to ADB_SERIAL or ANDROID_SERIAL")
+ commands = parser.add_subparsers(dest="command", required=True)
+
+ state = commands.add_parser("state", help="print machine-readable session state")
+ state.add_argument("--json", action="store_true", help="accepted for explicit machine-readable usage")
+
+ start = commands.add_parser("start", help="start Steam or the desktop")
+ start.add_argument("mode", choices=["steam", "desktop"])
+ start.add_argument("--ui", choices=["bigpicture", "desktop"], default="bigpicture")
+ start.add_argument("--url", help="steam:// URL to pass to the client")
+ start.add_argument("--wait", action="store_true", help="wait for READY after starting")
+ start.add_argument("--timeout", type=float, default=90)
+
+ run = commands.add_parser("run", help="run a guest program under Gamescope")
+ run.add_argument("program")
+ run.add_argument("arguments", nargs=argparse.REMAINDER)
+
+ wait = commands.add_parser("wait", help="wait for a session phase")
+ wait.add_argument("phase", choices=["ready", "idle", "failed"])
+ wait.add_argument("--timeout", type=float, default=90)
+
+ stop = commands.add_parser("stop", help="stop the session and wait for IDLE")
+ stop.add_argument("--timeout", type=float, default=90)
+ commands.add_parser("resume", help="resume a suspended session")
+
+ logs = commands.add_parser("logs", help="pull the latest session artifact directory")
+ logs.add_argument("which", choices=["latest"])
+ logs.add_argument("destination", nargs="?")
+ logs.add_argument("--timeout", type=float, default=90)
+
+ screenshot = commands.add_parser("screenshot", help="save a device screenshot")
+ screenshot.add_argument("path")
+ return parser
+
+
+def exit_code(exc: Exception) -> int:
+ if isinstance(exc, DeviceResolutionError):
+ return 3
+ code = getattr(exc, "code", "")
+ if code in {"ADB_NOT_FOUND", "ADB_FAILED", "INVALID_PROVIDER_RESPONSE"}:
+ return 3
+ if code in {"SESSION_ACTIVE", "NO_ACTIVE_SESSION", "SESSION_NOT_SUSPENDED", "SESSION_FAILED",
+ "GUEST_EXIT", "GUEST_START_FAILED", "RUNTIME_INSTALL_FAILED", "DESKTOP_INSTALL_FAILED",
+ "ACTIVITY_START_FAILED"}:
+ return 4
+ if code in {"TIMEOUT", "ARTIFACT_TIMEOUT"}:
+ return 5
+ if code in {"NO_SESSION_LOGS", "LOG_PULL_FAILED", "SCREENSHOT_FAILED", "FILE_IO"}:
+ return 6
+ return 2
+
+
+def main() -> int:
+ args = build_parser().parse_args()
+ serial = None
+ try:
+ adb = adb_path(args.adb)
+ serial = resolve_serial(adb, args.serial, log=True)
+ result = execute(args, adb, serial)
+ print(json.dumps(result, indent=2, sort_keys=True))
+ return 0
+ except (ControlError, DeviceResolutionError) as exc:
+ error = {"code": getattr(exc, "code", "DEVICE_RESOLUTION_FAILED"), "message": str(exc)}
+ response = {"ok": False, "error": error}
+ if serial:
+ response["deviceSerial"] = serial
+ print(json.dumps(response, indent=2, sort_keys=True))
+ return exit_code(exc)
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())