diff --git a/.github/workflows/pr-release-publish.yml b/.github/workflows/pr-release-publish.yml index 72ef506be..1759674c1 100644 --- a/.github/workflows/pr-release-publish.yml +++ b/.github/workflows/pr-release-publish.yml @@ -79,8 +79,9 @@ jobs: } stable=$(package_line "$RUNNER_TEMP/stable/DroidDeck-$stable_tag.apk") candidate=$(package_line "${apks[0]}") - [[ "$stable" =~ ^com\.droiddeck\.launcher\ [0-9]+$ && "$candidate" == "$stable" ]] || { - echo "::error::PR package/versionCode '$candidate' differs from stable '$stable'"; exit 1; + [[ "$stable" =~ ^com\.droiddeck\.launcher\ [0-9]+$ && "$candidate" =~ ^com\.droiddeck\.launcher\ [0-9]+$ && + ${candidate##* } -ge ${stable##* } ]] || { + echo "::error::PR package/versionCode '$candidate' cannot install over stable '$stable'"; exit 1; } current=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER") diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2620b96df..870c6b5f2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,6 +35,10 @@ jobs: run: | set -euo pipefail [[ $VERSION =~ ^[0-9A-Za-z._-]+$ ]] || { echo "::error::version '$VERSION': letters, digits, . _ - only"; exit 1; } + name=$(sed -n 's/^ *versionName "\(.*\)"$/\1/p' app/build.gradle) + [[ $VERSION == "$name" || $VERSION == "$name"-* ]] || { + echo "::error::version '$VERSION' does not match versionName '$name' in app/build.gradle: bump versionName and versionCode first"; exit 1; + } runs=$(gh run list -R "$GITHUB_REPOSITORY" --workflow build.yml --branch main --commit "$GITHUB_SHA" \ --status success --json databaseId,event,headSha -L 20) id=$(jq -r --arg sha "$GITHUB_SHA" \ diff --git a/app/build.gradle b/app/build.gradle index 666501a65..4195f0be8 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -63,8 +63,8 @@ android { // file out of its own data directory, and this app's entire job is to exec a rootfs: // proot, gamescope, the Steam client and everything it launches all live under files/. targetSdkVersion 28 - versionCode 8 - versionName "0.1.7" + versionCode 9 + versionName "0.2.0" buildConfigField "String", "BUILD_LABEL", "\"${escapedBuildLabel}\"" ndk { diff --git a/app/src/main/java/com/droiddeck/launcher/HomeApp.kt b/app/src/main/java/com/droiddeck/launcher/HomeApp.kt index f71ca928a..cc1b2afc0 100644 --- a/app/src/main/java/com/droiddeck/launcher/HomeApp.kt +++ b/app/src/main/java/com/droiddeck/launcher/HomeApp.kt @@ -97,13 +97,11 @@ object HomeApp { val intent = Intent(Intent.ACTION_MAIN) .addCategory(Intent.CATEGORY_LAUNCHER) .setComponent(ComponentName(app.packageName, app.className)) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) if (displayId == null) { context.startActivity(intent) return } - // Keep the target app's task separate from DroidDeck. Without this, Android can put the - // app on top of the caller's task and move the entire task to the chosen display. - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) val options = ActivityOptions.makeBasic().setLaunchDisplayId(displayId) context.startActivity(intent, options.toBundle()) } diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index d10c89eb0..ae6c63ec4 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -264,13 +264,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { handleHomeGuideIntent(intent) updateOnScreenControls() - WaylandCompositor.setFirstFrameListener { - SessionEvents.firstFrame() - runOnUiThread { - loading.visible = false - hud.start() - } - } + WaylandCompositor.setFirstFrameListener(firstFrameListener) SessionEvents.markReadyIfPossible() SessionState.endListener = endListener // A single Back opens the session menu; two quick presses/swipes send the Steam QAM chord. @@ -1537,6 +1531,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { /** Ours, so a finishing activity never unhooks the one that replaced it. */ private val endListener: (Int) -> Unit = { status -> onSessionEnded(status) } + private val firstFrameListener = Runnable { + SessionEvents.firstFrame() + runOnUiThread { + loading.visible = false + hud.start() + } + } override fun onDestroy() { // Deliberately does NOT end the session: this activity can be destroyed while the user is @@ -1549,7 +1550,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { if (::hud.isInitialized) hud.stop() padBridge?.stop() if (SessionState.endListener === endListener) SessionState.endListener = null - WaylandCompositor.setFirstFrameListener(null) + WaylandCompositor.clearFirstFrameListener(firstFrameListener) super.onDestroy() } diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntimeInstaller.java b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntimeInstaller.java index 66f8f918a..4643e9b40 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntimeInstaller.java +++ b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntimeInstaller.java @@ -210,6 +210,8 @@ public final class LinuxRuntimeInstaller { // runtime that isInstalled() would happily launch. File root = LinuxRuntime.rootDir(context); File staging = new File(root.getParentFile(), LinuxRuntime.DIR + ".new"); + File old = new File(root.getParentFile(), LinuxRuntime.DIR + ".old"); + recoverInterruptedSwap(root, staging, old); FileUtils.delete(staging); if (!staging.mkdirs()) return false; if (listener != null) listener.onProgress("Extracting", -1); @@ -219,7 +221,6 @@ public final class LinuxRuntimeInstaller { } FileUtils.writeString(new File(staging, VERSION_FILE), release.version); - File old = new File(root.getParentFile(), LinuxRuntime.DIR + ".old"); FileUtils.delete(old); if (root.isDirectory() && !root.renameTo(old)) { FileUtils.delete(staging); @@ -244,6 +245,8 @@ public final class LinuxRuntimeInstaller { } if (!staging.renameTo(root)) { + File keptTo = new File(staging, USER_DATA); + if (keptTo.isDirectory() && old.isDirectory()) keptTo.renameTo(new File(old, USER_DATA)); if (old.isDirectory()) old.renameTo(root); return false; } @@ -257,6 +260,24 @@ public final class LinuxRuntimeInstaller { } } + private static void recoverInterruptedSwap(File root, File staging, File old) { + File target = root.isDirectory() ? root : old; + File stagedHome = new File(staging, USER_DATA); + File home = new File(target, USER_DATA); + if (target.isDirectory() && stagedHome.isDirectory() && (!home.exists() || isEmptyDir(home))) { + FileUtils.delete(home); + if (stagedHome.renameTo(home)) Log.w(TAG, "recovered " + USER_DATA + " from an interrupted update"); + } + if (!root.isDirectory() && old.isDirectory() && old.renameTo(root)) { + Log.w(TAG, "restored the previous runtime after an interrupted update"); + } + } + + private static boolean isEmptyDir(File dir) { + String[] names = dir.list(); + return dir.isDirectory() && names != null && names.length == 0; + } + /** Removes the runtime; returns false without touching it while an install is running. */ public static boolean uninstall(Context context) { synchronized (JOB_LOCK) { diff --git a/app/src/main/java/com/droiddeck/launcher/session/PerfHud.kt b/app/src/main/java/com/droiddeck/launcher/session/PerfHud.kt index ae3326c1e..b0d49257b 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/PerfHud.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/PerfHud.kt @@ -81,7 +81,7 @@ class PerfHud(context: Context) { fun stop() { running = false - WaylandCompositor.setGameListener(null) + WaylandCompositor.clearGameListener(listener) text = "" } diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt index 419b4258f..87d690743 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -57,6 +57,7 @@ import java.util.Locale */ class SessionService : Service() { private val components = java.util.concurrent.CopyOnWriteArrayList() + private val stopLock = Any() private var wakeLock: PowerManager.WakeLock? = null private var wifiLock: WifiManager.WifiLock? = null private var sessionPid = -1 @@ -84,7 +85,7 @@ class SessionService : Service() { } } /** Counts sessions this service has started; a process exit from an earlier one is ignored. */ - private var sessionGen = 0 + @Volatile private var sessionGen = 0 override fun onCreate() { super.onCreate() @@ -392,6 +393,11 @@ class SessionService : Service() { networkLink.attach(this) networkLink.publish() components.add(networkLink) + if (gen != sessionGen || !SessionState.running) { + Log.i(TAG, "session stopped while it was starting; not launching it") + if (gen == sessionGen) components.clear() + return + } components.forEach { it.start() } val line = command.joinToString(" ") { @@ -402,7 +408,7 @@ class SessionService : Service() { // by the teardown a second after the new one has started, and its exit used to arrive // here as "session ended: 137" and end the NEW session. An exit belongs to the session // that started it. - sessionPid = HostProcess.start(line, hostEnv.asArray(), root, { status -> + val pid = HostProcess.start(line, hostEnv.asArray(), root, { status -> if (gen != sessionGen) { Log.i(TAG, "an earlier session's process ended ($status); the current one carries on") return@start @@ -410,12 +416,24 @@ class SessionService : Service() { Log.i(TAG, "session ended: $status") 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) + Log.i(TAG, "session pid $pid, log ${sessionLog.path}") + if (gen != sessionGen || !SessionState.running) { + Log.i(TAG, "session stopped while its guest was starting; taking it down") + if (gen == sessionGen) { + launchWatcher?.stopWatching() + launchWatcher = null + components.reversed().forEach { runCatching { it.stop() } } + components.clear() + } + if (pid > 1) Thread({ teardown(pid) }, "session-teardown").start() + return + } + sessionPid = pid + if (pid > 1) { + SessionEvents.guestStarted(pid) + } else { + SessionEvents.record("guest.start_failed", mapOf("pid" to pid)) + SessionEvents.fail("GUEST_START_FAILED", "The guest process could not be started", pid) stopSession(-1) return } @@ -877,8 +895,12 @@ class SessionService : Service() { } } - private fun finishSessionStop(status: Int) { + private fun finishSessionStop(status: Int, stoppedGen: Int) { mainHandler.post { + if (stoppedGen != sessionGen || SessionState.running) { + Log.i(TAG, "session $stoppedGen finished stopping after a new one started") + return@post + } SessionState.suspended = false SessionState.guestPid = -1 releaseLocks() @@ -897,8 +919,11 @@ class SessionService : Service() { } private fun stopSession(status: Int) { - if (!SessionState.running) return - SessionState.running = false + synchronized(stopLock) { + if (!SessionState.running) return + SessionState.running = false + } + val stoppedGen = sessionGen SessionState.stopRequested = false SessionEvents.record("guest.exited", mapOf("status" to status)) SessionEvents.transition(SessionPhase.STOPPING, "session.stopping", mapOf("status" to status)) @@ -942,10 +967,10 @@ class SessionService : Service() { auxiliary.forEach { (pid, started) -> teardown(pid, started) } if (prootPid > 1) teardown(prootPid) killGuestLeftovers() - finishSessionStop(status) + finishSessionStop(status, stoppedGen) }, "session-teardown").start() } else { - finishSessionStop(status) + finishSessionStop(status, stoppedGen) } } if (prootPid > 1) acquireLocks() @@ -1127,6 +1152,10 @@ class SessionService : Service() { } fun stop(context: Context) { + if (!SessionState.running) { + SessionState.stopRequested = true + return + } context.startService(Intent(context, SessionService::class.java).setAction(ACTION_STOP)) } diff --git a/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java b/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java index ffd1a28e5..7b7717869 100644 --- a/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java +++ b/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java @@ -26,6 +26,10 @@ public final class WaylandCompositor { * listener must marshal to the UI thread itself. */ public static void setFirstFrameListener(Runnable r) { firstFrameListener = r; } + public static synchronized void clearFirstFrameListener(Runnable r) { + if (firstFrameListener == r) firstFrameListener = null; + } + /** Invoked from native (banner_on_first_frame) on the first present. */ @SuppressWarnings("unused") static void onFirstFramePresented() { @@ -52,6 +56,10 @@ public final class WaylandCompositor { public static void setGameListener(GameListener l) { gameListener = l; } + public static synchronized void clearGameListener(GameListener l) { + if (gameListener == l) gameListener = null; + } + /** Invoked from native (banner_on_game_surface). */ @SuppressWarnings("unused") static void onGameSurface(String window, String gpuName) { diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session index 4e8402291..c0adc95e7 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session @@ -591,6 +591,8 @@ BUSCONF wait "$decky_supervisor" 2>/dev/null || true echo "== Decky: session supervisor stopped" fi + _bl_jobs=$(jobs -p) + [ -n "$_bl_jobs" ] && kill $_bl_jobs 2>/dev/null reap_orphans fi exit "$rc"