Merge branch 'main' into feature/controller-editor

This commit is contained in:
MaxsTechReview authored and GitHub committed 2026-09-24 22:38:46 -04:00
commit 046eb9e319
13 files changed
+1076 -22

No files matched your search

+19
View File
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<!-- Debug builds only. Android grants DUMP to adb shell; release APKs have no bridge. -->
<activity
android:name=".agent.AgentStartActivity"
android:exported="true"
android:excludeFromRecents="true"
android:noHistory="true"
android:permission="android.permission.DUMP"
android:theme="@style/SessionTheme" />
<provider
android:name=".agent.AgentBridgeProvider"
android:authorities="${applicationId}.agent"
android:exported="true"
android:permission="android.permission.DUMP"
android:grantUriPermissions="false" />
</application>
</manifest>
@@ -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<File> { 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<out String>?, selection: String?, selectionArgs: Array<out String>?, 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<out String>?): Int = 0
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?): 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,
)
}
}
@@ -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"
}
}
@@ -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: "<what> · 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)
@@ -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) {
@@ -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<String, Any?> = emptyMap(),
) {
synchronized(SessionState) {
if (SessionState.phase != phase) {
SessionState.phase = phase
SessionState.lastTransitionAt = System.currentTimeMillis()
}
}
record(event, fields)
}
fun record(event: String, fields: Map<String, Any?> = 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
}
}
@@ -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)")
@@ -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"
@@ -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<String> = 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,
}
+41
View File
@@ -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`.
+105
View File
@@ -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())
+1 -16
View File
@@ -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')
+351
View File
@@ -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())