mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-12 03:21:08 +00:00
Merge branch 'main' into feature/controller-editor
This commit is contained in:
commit
046eb9e319
13 files changed
+1076
-22
No files matched your search
@@ -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,
|
||||
}
|
||||
@@ -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`.
|
||||
Executable
+105
@@ -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
@@ -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')
|
||||
|
||||
Executable
+351
@@ -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())
|
||||
Reference in new issue
Block a user