Compare commits

...
13 changed files with 500 additions and 37 deletions

No files matched your search

@@ -109,6 +109,7 @@ class MainActivity : ComponentActivity() {
private var frameGenLabel by mutableStateOf("")
private var lossless by mutableStateOf(Lossless.State.NONE)
private var showRemove by mutableStateOf(false)
private var showRuntimeActions by mutableStateOf<LinuxRuntimeInstaller.Release?>(null)
private var pendingSwitch by mutableStateOf<Intent?>(null)
private var switchingTo: Intent? = null
private var switchPoll: Runnable? = null
@@ -1106,6 +1107,13 @@ class MainActivity : ComponentActivity() {
onDismiss = { pendingSwitch = null },
)
}
showRuntimeActions?.let { release ->
com.droiddeck.launcher.ui.RuntimeActionsDialog(
onRepair = { showRuntimeActions = null; if (!runtimeChangesBlocked()) install(release) },
onRemove = { showRuntimeActions = null; showRemove = true },
onDismiss = { showRuntimeActions = null },
)
}
if (showRemove) ConfirmDialog(
title = androidx.compose.ui.res.stringResource(R.string.main_remove_runtime_title),
text = androidx.compose.ui.res.stringResource(R.string.main_remove_runtime_text),
@@ -1262,6 +1270,7 @@ class MainActivity : ComponentActivity() {
syncLossless()
updates.onResume()
drivers.checkOnOpen()
resumeInterruptedPackages()
protons.checkUpdates()
if (!SessionState.running) Thread({
val applied = runCatching { ComponentsManager.applyQueued(this) }.getOrDefault(emptyList())
@@ -1515,6 +1524,27 @@ class MainActivity : ComponentActivity() {
.putExtra(SessionService.EXTRA_PROGRAM, path))
}
private fun resumeInterruptedPackages() {
if (runtimeChangesBlocked()) return
val first = DesktopCatalog.interrupted(this).firstOrNull()
if (first == null) {
Thread({ DesktopCatalog.pruneDownloads(this) }, "prune-pkg").start()
return
}
pkgId = first.id; pkgStage = getString(R.string.deskpkg_installing, first.name); pkgPercent = -1
Thread({
val problem = DesktopCatalog.resumeInterrupted(this) { stage, percent ->
ui.post { pkgStage = stage; pkgPercent = percent }
}
ui.post {
pkgStage = null; pkgId = null
if (problem != null) android.widget.Toast.makeText(this, getString(R.string.main_pkg_problem, first.name, problem), android.widget.Toast.LENGTH_LONG).show()
refreshPackages()
refresh()
}
}, "resume-pkg").start()
}
private fun installPackage(id: String) {
val entry = catalog?.firstOrNull { it.id == id } ?: return
if (busy || LinuxRuntimeInstaller.isBusy() || pkgStage != null || SessionState.running) return
@@ -1960,10 +1990,14 @@ class MainActivity : ComponentActivity() {
private fun onRuntimeButton() {
if (runtimeChangesBlocked()) return
val release = available
if (removalPending || installed != null && release?.version == installed) {
if (removalPending) {
showRemove = true
return
}
if (installed != null && release?.version == installed) {
showRuntimeActions = release
return
}
if (release == null) {
Thread({ checkCatalog() }, "catalog").start()
return
@@ -500,12 +500,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
loading = LoadingState(this, steam = loadingMode() == SessionService.MODE_STEAM)
if (!SessionState.running) {
val needRuntime = com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.installedVersion(this) == null
val repair = intent.getBooleanExtra(EXTRA_REPAIR_RUNTIME, false)
intent.removeExtra(EXTRA_REPAIR_RUNTIME)
val needRuntime = repair || com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.installedVersion(this) == null
val needResume = !needRuntime && com.droiddeck.launcher.runtime.DesktopCatalog.interrupted(this).isNotEmpty()
val needDesktop = intent.getStringExtra(SessionService.EXTRA_MODE) == SessionService.MODE_DESKTOP &&
!com.droiddeck.launcher.runtime.DesktopCatalog.desktopInstalled(this)
(repair || !com.droiddeck.launcher.runtime.DesktopCatalog.desktopInstalled(this))
val needProton = (intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM) == SessionService.MODE_STEAM &&
com.droiddeck.launcher.runtime.DesktopCatalog.protonSeedNeeded(this)
if (needRuntime || needDesktop || needProton) installThenStart(needRuntime, needDesktop, needProton)
if (needRuntime || needResume || needDesktop || needProton) installThenStart(needRuntime, needDesktop, needProton, needResume)
}
hud = PerfHud(this)
hud.onPresentingWindowChanged = {
@@ -635,6 +638,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
onCancel = { SessionService.stop(this@SessionActivity); finish() },
endedDetail = loading.endedDetail,
onRetry = { retrySession() },
onRepair = if (repairOffered) ({ repairRuntime() }) else null,
onShareLogs = { shareCurrentSessionLogs() },
onClose = { finish() },
flood = floodColor?.let { androidx.compose.ui.graphics.Color(it) },
@@ -1320,17 +1324,18 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
@Volatile private var installingRuntime = false
private var repairOffered by mutableStateOf(false)
private fun installThenStart(runtime: Boolean, desktop: Boolean, proton: Boolean = false) {
private fun installThenStart(runtime: Boolean, desktop: Boolean, proton: Boolean = false, resume: Boolean = false) {
if (SessionState.stopRequested) return
installingRuntime = true
SessionState.installing = if (runtime) "runtime" else if (desktop) "desktop" else "proton"
SessionState.installing = if (runtime) "runtime" else if (resume) "package" else if (desktop) "desktop" else "proton"
SessionEvents.transition(
SessionPhase.INSTALLING_RUNTIME,
"${SessionState.installing}.installing",
mapOf("component" to SessionState.installing),
)
val first = if (runtime) RUNTIME_LINES else if (desktop) DESKTOP_LINES else PROTON_LINES
val first = if (runtime || resume) RUNTIME_LINES else if (desktop) DESKTOP_LINES else PROTON_LINES
loading.say(getString(first.downloading), first.topic, readable = true)
loading.percent = -1
Thread({
@@ -1340,6 +1345,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
problem = installRuntime()
if (problem == null) SessionEvents.record("runtime.ready") else failedComponent = "runtime"
}
if (problem == null && resume && !SessionState.stopRequested) {
SessionState.installing = "package"
SessionEvents.transition(SessionPhase.INSTALLING_RUNTIME, "package.resuming", mapOf("component" to "package"))
problem = com.droiddeck.launcher.runtime.DesktopCatalog.resumeInterrupted(this, progressAsIs())
?.let { getString(R.string.session_package_resume_failed, it) }
if (problem == null) SessionEvents.record("package.ready") else failedComponent = "package"
}
if (problem == null && desktop && !SessionState.stopRequested) {
SessionState.installing = "desktop"
SessionEvents.transition(SessionPhase.INSTALLING_RUNTIME, "desktop.installing", mapOf("component" to "desktop"))
@@ -1364,7 +1376,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
return@post
}
if (problem != null) {
val code = if (failedComponent == "runtime") "RUNTIME_INSTALL_FAILED" else "DESKTOP_INSTALL_FAILED"
val code = when (failedComponent) {
"runtime" -> "RUNTIME_INSTALL_FAILED"
"package" -> "PACKAGE_RESUME_FAILED"
else -> "DESKTOP_INSTALL_FAILED"
}
SessionEvents.fail(code, problem)
collectStartArtifacts("session start failed")
loading.showEnded(problem)
@@ -1412,9 +1428,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
}
private fun progressAsIs() = com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.ProgressListener { stage, percent ->
uiHandler.post {
loading.percent = percent
loading.say(stage, LoadingState.Topic.RUNTIME, readable = true)
}
}
private fun installRuntime(): String? {
val release = com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.fetchRelease()
?: return getString(R.string.session_runtime_catalog_unreachable)
runCatching { com.droiddeck.launcher.runtime.RuntimeInstallService.start(this, release) }
val ok = com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.install(this, release,
progressFor(RUNTIME_LINES, release.size / 1_000_000))
return if (ok) null else getString(R.string.session_runtime_install_failed)
@@ -1714,7 +1738,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
return
}
Thread({
val hint = sessionEndHint(SessionState.logFile)
val hint = runtimeDamageHint() ?: sessionEndHint(SessionState.logFile)
runOnUiThread {
if (isFinishing || isDestroyed) return@runOnUiThread
showEnded(status, hint)
@@ -1733,6 +1757,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
SessionService.MODE_DESKTOP -> getString(R.string.session_ended_desktop)
else -> getString(R.string.session_ended_named, "Steam")
}
repairOffered = hint?.repair == true
loading.showEnded(
hint?.text ?: stopped,
getString(R.string.session_ended_detail, status, SessionState.logFile?.path ?: getString(R.string.session_ended_no_log)),
@@ -1751,6 +1776,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
uiHandler.post { sessionOverlay.requestFocus() }
}
private fun repairRuntime() {
val again = Intent(intent).putExtra(EXTRA_REPAIR_RUNTIME, true)
closeAtOnce = true
finish()
startActivity(again)
}
private fun retrySession() {
val again = Intent(intent)
closeAtOnce = true
@@ -1758,7 +1790,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
startActivity(again)
}
private class EndHint(val text: String, val evenOnSuccess: Boolean = false)
private class EndHint(val text: String, val evenOnSuccess: Boolean = false, val repair: Boolean = false)
private fun runtimeDamageHint(): EndHint? {
val broken = runCatching { com.droiddeck.launcher.runtime.RuntimeHealth.brokenLibraryLinks(this) }.getOrDefault(emptyList())
if (broken.isEmpty()) return null
Log.w(TAG, "runtime has ${broken.size} broken library link(s): ${broken.take(8).joinToString()}")
return EndHint(resources.getQuantityString(R.plurals.session_end_runtime_damaged, broken.size, broken.size, broken.first()), repair = true)
}
private fun sessionEndHint(log: File?): EndHint? {
if (log == null || !log.isFile) return null
@@ -3186,6 +3225,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
companion object {
const val EXTRA_RETURN_HOME = "returnHome"
const val EXTRA_STEAMVR = "steamVr"
const val EXTRA_REPAIR_RUNTIME = "repairRuntime"
private const val LEAVE_TIMEOUT_MS = 1_500L
private const val TAG = "SessionActivity"
private var startedScreens = 0
@@ -224,6 +224,9 @@ object DeviceReport {
k("Installed version", LinuxRuntimeInstaller.installedVersion(context))
k("Runtime ready", LinuxRuntime.isInstalled(context))
k("Desktop installed", DesktopCatalog.desktopInstalled(context))
k("Interrupted installs", DesktopCatalog.interrupted(context).joinToString { "${it.id} ${it.version}" }.ifEmpty { "none" })
val broken = runCatching { com.droiddeck.launcher.runtime.RuntimeHealth.brokenLibraryLinks(context) }.getOrDefault(emptyList())
k("Broken library links", if (broken.isEmpty()) "none" else "${broken.size}: ${broken.take(8).joinToString()}")
k("Runtime root", LinuxRuntime.rootDir(context).path)
h("Settings in effect")
@@ -47,6 +47,53 @@ object DesktopCatalog {
private fun marker(context: Context, id: String) = File(LinuxRuntime.rootDir(context), ".droiddeck-pkg-$id")
private const val JOURNAL_SUFFIX = ".installing"
private fun journal(root: File, id: String) = File(root, ".droiddeck-pkg-$id$JOURNAL_SUFFIX")
/** Packages whose extraction over the runtime started and never finished. */
fun interrupted(context: Context): List<Entry> = interrupted(LinuxRuntime.rootDir(context))
fun interrupted(root: File): List<Entry> =
root.listFiles { f -> f.name.startsWith(".droiddeck-pkg-") && f.name.endsWith(JOURNAL_SUFFIX) }.orEmpty()
.sortedBy { it.name }
.mapNotNull { f -> FileUtils.readString(f)?.let { runCatching { entryFrom(JSONObject(it)) }.getOrNull() } }
private val installLock = Any()
private fun downloadFile(context: Context, entry: Entry) = File(context.cacheDir, "pkg-${entry.id}-${entry.sha256.take(12)}.download")
/** Drops downloads kept for installs that no longer need finishing, such as after the runtime was reinstalled. */
fun pruneDownloads(context: Context) = synchronized(installLock) {
val keep = interrupted(context).map { downloadFile(context, it).name }.toSet()
context.cacheDir.listFiles { f -> f.name.startsWith("pkg-") && f.name.removeSuffix(".resume.properties").endsWith(".download") }
?.filter { it.name.removeSuffix(".resume.properties") !in keep }?.forEach { it.delete() }
}
fun resumeInterrupted(context: Context, listener: LinuxRuntimeInstaller.ProgressListener?): String? = synchronized(installLock) {
for (entry in interrupted(context)) {
Log.w(TAG, "finishing the interrupted install of ${entry.id} ${entry.version}")
install(context, entry, listener)?.let { return it }
}
null
}
internal fun entryJson(e: Entry): JSONObject = JSONObject()
.put("id", e.id).put("name", e.name).put("tier", e.tier).put("version", e.version).put("kind", e.kind)
.put("url", e.url).put("sha256", e.sha256).put("size", e.size).put("notes", e.notes)
.put("icon", e.icon).put("category", e.category)
internal fun entryFrom(o: JSONObject) = Entry(
o.getString("id"), o.getString("name"), o.optInt("tier", 1), o.getString("version"), o.getString("kind"),
o.getString("url"), o.getString("sha256"), o.optLong("size", 0L), o.optString("notes", ""),
o.optString("icon", "applications-games"), o.optString("category", "Game"),
)
/** A finished download is kept until its extraction completes, so resuming does not fetch it again. */
private fun downloaded(file: File, entry: Entry): Boolean =
file.isFile && file.length() > 0 && !File(file.path + ".resume.properties").exists() &&
(entry.size <= 0 || file.length() == entry.size) && entry.sha256.equals(Hashes.sha256(file), ignoreCase = true)
fun installed(context: Context, id: String): String? =
FileUtils.readString(marker(context, id))?.trim()?.takeIf { it.isNotEmpty() }
@@ -82,22 +129,38 @@ object DesktopCatalog {
fun removeDesktop(context: Context, listener: LinuxRuntimeInstaller.ProgressListener): String? =
DesktopRemoval.remove(context, listener)
fun install(context: Context, entry: Entry, listener: LinuxRuntimeInstaller.ProgressListener?): String? {
fun install(context: Context, entry: Entry, listener: LinuxRuntimeInstaller.ProgressListener?): String? = synchronized(installLock) {
installLocked(context, entry, listener)
}
private fun installLocked(context: Context, entry: Entry, listener: LinuxRuntimeInstaller.ProgressListener?): String? {
val root = LinuxRuntime.rootDir(context)
if (!root.isDirectory) return context.getString(R.string.deskpkg_runtime_missing)
if (entry.sha256.isEmpty()) return context.getString(R.string.deskpkg_no_checksum, entry.name)
val download = File(context.cacheDir, "pkg-${entry.id}.download")
try {
val downloading = context.getString(R.string.user_apps_downloading, entry.name)
listener?.onProgress(LinuxRuntimeInstaller.Step.DOWNLOADING, downloading, 0)
val ok = Downloader.downloadFile(entry.url, download, true, entry.sha256) { f ->
listener?.onProgress(LinuxRuntimeInstaller.Step.DOWNLOADING, downloading, if (f < 0) -1 else Math.round(f * 100f))
val download = downloadFile(context, entry)
val journal = journal(root, entry.id)
var keepDownload = false
val release = RuntimeInstallService.holdForPackage(context, context.getString(R.string.deskpkg_installing, entry.name))
val report = object : LinuxRuntimeInstaller.ProgressListener {
override fun onProgress(stage: String, percent: Int) = onProgress(LinuxRuntimeInstaller.Step.OTHER, stage, percent)
override fun onProgress(step: LinuxRuntimeInstaller.Step, stage: String, percent: Int) {
RuntimeInstallService.packageProgress(stage, percent)
listener?.onProgress(step, stage, percent)
}
if (!ok) return context.getString(R.string.user_apps_download_failed)
listener?.onProgress(LinuxRuntimeInstaller.Step.VERIFYING, context.getString(R.string.rtinst_verifying), -1)
val actual = Hashes.sha256(download)
if (!entry.sha256.equals(actual, ignoreCase = true)) return context.getString(R.string.deskpkg_checksum_mismatch)
listener?.onProgress(context.getString(R.string.deskpkg_installing, entry.name), -1)
}
try {
if (!downloaded(download, entry)) {
val downloading = context.getString(R.string.user_apps_downloading, entry.name)
report.onProgress(LinuxRuntimeInstaller.Step.DOWNLOADING, downloading, 0)
val ok = Downloader.downloadFile(entry.url, download, true, entry.sha256) { f ->
report.onProgress(LinuxRuntimeInstaller.Step.DOWNLOADING, downloading, if (f < 0) -1 else Math.round(f * 100f))
}
if (!ok) return context.getString(R.string.user_apps_download_failed)
report.onProgress(LinuxRuntimeInstaller.Step.VERIFYING, context.getString(R.string.rtinst_verifying), -1)
val actual = Hashes.sha256(download)
if (!entry.sha256.equals(actual, ignoreCase = true)) return context.getString(R.string.deskpkg_checksum_mismatch)
}
report.onProgress(context.getString(R.string.deskpkg_installing, entry.name), -1)
when (entry.kind) {
"appimage" -> {
val dir = File(root, "opt/appimages").apply { mkdirs() }
@@ -112,23 +175,28 @@ object DesktopCatalog {
"Icon=${entry.icon}\nTerminal=false\nCategories=${entry.category};\n")
}
else -> {
check(FileUtils.writeString(journal, entryJson(entry).toString())) { "Could not record the package installation" }
keepDownload = true
marker(context, entry.id).delete()
if (!LinuxRuntimeInstaller.extract(context, download, root, listener)) return context.getString(R.string.deskpkg_extract_failed)
if (!LinuxRuntimeInstaller.extract(context, download, root, report)) return context.getString(R.string.deskpkg_extract_failed)
}
}
if (entry.id == DESKTOP_ID) {
cleanLegacy(context, listener)
cleanLegacy(context, report)
val pending = File(root, DesktopRemoval.PENDING)
check(!pending.exists() || pending.delete()) { "Could not finish desktop installation" }
}
FileUtils.writeString(marker(context, entry.id), entry.version)
check(installed(context, entry.id) == entry.version) { "Could not finish package installation" }
check(!journal.exists() || journal.delete()) { "Could not finish package installation" }
keepDownload = false
return null
} catch (e: Exception) {
Log.e(TAG, "install ${entry.id}", e)
return e.message ?: context.getString(R.string.deskpkg_install_failed)
} finally {
download.delete()
if (!keepDownload) download.delete()
release()
}
}
@@ -456,9 +456,12 @@ public final class LinuxRuntimeInstaller {
}
}
// Packages extract over the live runtime and their archives list a link before the file it
// names, so a kill mid-way must leave neither a truncated file nor a link to a missing one.
public static boolean extract(Context context, File archive, File destination, ProgressListener listener) {
long entries = 0;
String extracting = context.getString(R.string.rtinst_extracting);
java.util.LinkedHashMap<File, String> links = new java.util.LinkedHashMap<>();
try (InputStream in = new ZstdCompressorInputStream(
new BufferedInputStream(new FileInputStream(archive), 1 << 16));
TarArchiveInputStream tar = new TarArchiveInputStream(in)) {
@@ -477,29 +480,35 @@ public final class LinuxRuntimeInstaller {
if (parent != null && !parent.isDirectory()) parent.mkdirs();
if (entry.isSymbolicLink()) {
file.delete();
FileUtils.symlink(entry.getLinkName(), file.getAbsolutePath());
links.remove(file);
links.put(file, entry.getLinkName());
} else if (entry.isLink()) {
File target = ArchivePaths.inside(destination, entry.getLinkName());
if (target == null) {
Log.w(TAG, "skipping hard link outside the rootfs: " + entry.getLinkName());
continue;
}
file.delete();
links.remove(file);
File part = partFile(file);
part.delete();
try {
Files.createLink(file.toPath(), target.toPath());
Files.createLink(part.toPath(), target.toPath());
} catch (IOException | UnsupportedOperationException e) {
if (target.isFile()) {
Files.copy(target.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING);
Files.copy(target.toPath(), part.toPath(), StandardCopyOption.REPLACE_EXISTING);
}
}
if (part.exists()) place(part, file);
} else if (entry.isFile()) {
try (OutputStream out = new FileOutputStream(file)) {
links.remove(file);
File part = partFile(file);
try (OutputStream out = new FileOutputStream(part)) {
byte[] buffer = new byte[1 << 16];
int read;
while ((read = tar.read(buffer)) != -1) out.write(buffer, 0, read);
}
if ((entry.getMode() & 0111) != 0) file.setExecutable(true, false);
if ((entry.getMode() & 0111) != 0) part.setExecutable(true, false);
place(part, file);
} else {
continue;
}
@@ -507,10 +516,32 @@ public final class LinuxRuntimeInstaller {
listener.onProgress(extracting, -1);
}
}
for (java.util.Map.Entry<File, String> link : links.entrySet()) {
File file = link.getKey();
File part = partFile(file);
try {
Files.deleteIfExists(part.toPath());
Files.createSymbolicLink(part.toPath(), java.nio.file.Paths.get(link.getValue()));
place(part, file);
} catch (IOException e) {
Log.w(TAG, "symlink " + file + " -> " + link.getValue() + ": " + e);
part.delete();
}
}
return true;
} catch (Exception e) {
Log.e(TAG, "extract", e);
return false;
}
}
static File partFile(File file) {
return new File(file.getParentFile(), ".dd-part-" + Integer.toHexString(file.getName().hashCode()));
}
private static void place(File part, File file) throws IOException {
java.nio.file.Path path = file.toPath();
if (Files.isDirectory(path, java.nio.file.LinkOption.NOFOLLOW_LINKS)) Files.delete(path);
Files.move(part.toPath(), path, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
}
}
@@ -0,0 +1,34 @@
package com.droiddeck.launcher.runtime
import android.content.Context
import java.io.File
import java.nio.file.Files
import java.nio.file.Path
object RuntimeHealth {
private const val MAX_HOPS = 16
fun brokenLibraryLinks(context: Context): List<String> = brokenLibraryLinks(LinuxRuntime.rootDir(context))
/** A complete runtime has no library link in usr/lib whose target is missing. */
fun brokenLibraryLinks(root: File): List<String> {
val lib = File(root, "usr/lib")
val names = lib.list() ?: return emptyList()
return names.sorted().filter { name ->
val path = File(lib, name).toPath()
Files.isSymbolicLink(path) && !resolves(root, path)
}
}
private fun resolves(root: File, link: Path): Boolean = try {
var path = link
var hops = 0
while (Files.isSymbolicLink(path) && hops++ < MAX_HOPS) {
val target = Files.readSymbolicLink(path)
path = if (target.isAbsolute) File(root, target.toString()).toPath() else path.parent.resolve(target)
}
!Files.isSymbolicLink(path) && Files.exists(path)
} catch (e: Exception) {
false
}
}
@@ -19,6 +19,7 @@ class RuntimeInstallService : Service() {
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_PACKAGE) return holdPackage(startId)
val removal = intent?.action == ACTION_REMOVE
val release = intent?.let(::releaseFrom)
startForeground(NOTIFICATION_ID, notification(getString(if (LinuxRuntimeInstaller.isMaintaining()) R.string.desktop_removing else if (removal) R.string.rtinst_removing else R.string.user_apps_starting), -1))
@@ -44,7 +45,27 @@ class RuntimeInstallService : Service() {
return START_NOT_STICKY
}
private fun notification(stage: String, percent: Int): Notification {
private fun holdPackage(startId: Int): Int {
startForeground(NOTIFICATION_ID, notification(packageStage, packagePercent, packageTitle))
Thread({
val manager = getSystemService(NotificationManager::class.java)
var shown = ""
while (packageHolds.get() > 0) {
val now = "$packageStage:$packagePercent"
if (now != shown) {
shown = now
manager?.notify(NOTIFICATION_ID, notification(packageStage, packagePercent, packageTitle))
}
Thread.sleep(500)
}
@Suppress("DEPRECATION")
stopForeground(true)
stopSelf(startId)
}, "package-install").start()
return START_NOT_STICKY
}
private fun notification(stage: String, percent: Int, title: String? = null): Notification {
val manager = getSystemService(NotificationManager::class.java)
manager?.createNotificationChannel(NotificationChannel(CHANNEL_ID, getString(R.string.setup_runtime),
NotificationManager.IMPORTANCE_LOW).apply {
@@ -58,7 +79,7 @@ class RuntimeInstallService : Service() {
PendingIntent.FLAG_IMMUTABLE)
return Notification.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_stat_session)
.setContentTitle(getString(if (LinuxRuntimeInstaller.isMaintaining()) R.string.desktop_removing else if (LinuxRuntimeInstaller.isRemoving()) R.string.rtinst_title_removing else R.string.rtinst_title_installing))
.setContentTitle(title ?: getString(if (LinuxRuntimeInstaller.isMaintaining()) R.string.desktop_removing else if (LinuxRuntimeInstaller.isRemoving()) R.string.rtinst_title_removing else R.string.rtinst_title_installing))
.setContentText(stage)
.setProgress(100, percent.coerceIn(0, 100), percent < 0)
.setContentIntent(open)
@@ -72,6 +93,11 @@ class RuntimeInstallService : Service() {
private const val CHANNEL_ID = "runtime-install"
private const val NOTIFICATION_ID = 2
private const val ACTION_REMOVE = "com.droiddeck.launcher.REMOVE_RUNTIME"
private const val ACTION_PACKAGE = "com.droiddeck.launcher.INSTALL_PACKAGE"
private val packageHolds = java.util.concurrent.atomic.AtomicInteger()
@Volatile private var packageTitle = ""
@Volatile private var packageStage = ""
@Volatile private var packagePercent = -1
private const val EXTRA_VERSION = "version"
private const val EXTRA_URL = "url"
private const val EXTRA_SHA256 = "sha256"
@@ -91,6 +117,24 @@ class RuntimeInstallService : Service() {
if (Build.VERSION.SDK_INT >= 26) context.startForegroundService(intent) else context.startService(intent)
}
/** Keeps the process in the foreground until the returned release runs, so closing the app does not stop the install. */
fun holdForPackage(context: Context, title: String): () -> Unit {
packageTitle = title
packageStage = title
packagePercent = -1
packageHolds.incrementAndGet()
val intent = Intent(context, RuntimeInstallService::class.java).setAction(ACTION_PACKAGE)
runCatching { if (Build.VERSION.SDK_INT >= 26) context.startForegroundService(intent) else context.startService(intent) }
.onFailure { android.util.Log.w("RuntimeInstallService", "package install runs without a foreground service: $it") }
val released = java.util.concurrent.atomic.AtomicBoolean()
return { if (released.compareAndSet(false, true)) packageHolds.decrementAndGet() }
}
fun packageProgress(stage: String, percent: Int) {
packageStage = stage
packagePercent = percent
}
private fun releaseFrom(intent: Intent): LinuxRuntimeInstaller.Release? {
val version = intent.getStringExtra(EXTRA_VERSION) ?: return null
val url = intent.getStringExtra(EXTRA_URL) ?: return null
@@ -66,6 +66,22 @@ fun ConfirmDialog(title: String, text: String, confirm: String, onConfirm: () ->
)
}
@Composable
fun RuntimeActionsDialog(onRepair: () -> Unit, onRemove: () -> Unit, onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.runtime_actions_title)) },
text = { Text(stringResource(R.string.runtime_actions_text)) },
confirmButton = { TextButton(onClick = onRepair) { Text(stringResource(R.string.runtime_repair)) } },
dismissButton = {
Row {
TextButton(onClick = onRemove) { Text(stringResource(R.string.store_remove)) }
TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_cancel)) }
}
},
)
}
@Composable
fun ResetSettingsDialog(onConfirm: (gameProfiles: Boolean) -> Unit, onDismiss: () -> Unit) {
var gameProfiles by remember { mutableStateOf(false) }
@@ -122,6 +122,7 @@ fun LoadingOverlay(
title: String = stringResource(R.string.load_starting_steam), steam: Boolean = true, onCancel: (() -> Unit)? = null,
endedDetail: String? = null,
onRetry: (() -> Unit)? = null,
onRepair: (() -> Unit)? = null,
onShareLogs: (() -> Unit)? = null,
onClose: (() -> Unit)? = null,
flood: Color? = null,
@@ -187,7 +188,7 @@ fun LoadingOverlay(
textAlign = TextAlign.Center, maxLines = 3, overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 12.dp).widthIn(max = 520.dp),
)
if (ended) EndedActions(onRetry, onShareLogs, onClose)
if (ended) EndedActions(onRetry, onRepair, onShareLogs, onClose)
}
if (!ended && hint.isNotEmpty()) Text(
hint, fontSize = 13.sp, lineHeight = 18.sp, color = colors.onSurfaceVariant.copy(alpha = 0.8f), textAlign = TextAlign.Center,
@@ -200,7 +201,7 @@ fun LoadingOverlay(
}
@Composable
private fun EndedActions(onRetry: (() -> Unit)?, onShareLogs: (() -> Unit)?, onClose: (() -> Unit)?) {
private fun EndedActions(onRetry: (() -> Unit)?, onRepair: (() -> Unit)?, onShareLogs: (() -> Unit)?, onClose: (() -> Unit)?) {
val first = remember { FocusRequester() }
LaunchedEffect(Unit) {
androidx.compose.runtime.withFrameNanos { }
@@ -209,7 +210,11 @@ private fun EndedActions(onRetry: (() -> Unit)?, onShareLogs: (() -> Unit)?, onC
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.padding(top = 22.dp)) {
var firstUsed = false
fun claim(): Modifier = if (firstUsed) Modifier else { firstUsed = true; Modifier.focusRequester(first) }
if (onRetry != null) PrimaryButton(stringResource(R.string.store_try_again), modifier = claim(), onClick = onRetry)
if (onRepair != null) PrimaryButton(stringResource(R.string.runtime_repair), modifier = claim(), onClick = onRepair)
if (onRetry != null) {
if (onRepair != null) SecondaryButton(stringResource(R.string.store_try_again), modifier = claim(), onClick = onRetry)
else PrimaryButton(stringResource(R.string.store_try_again), modifier = claim(), onClick = onRetry)
}
if (onShareLogs != null) SecondaryButton(stringResource(R.string.drawer_share_logs), modifier = claim(),
progress = com.droiddeck.launcher.session.SessionLogShare.progress, onClick = onShareLogs)
if (onClose != null) SecondaryButton(stringResource(R.string.mode_back), modifier = claim(), onClick = onClose)
+8
View File
@@ -1244,6 +1244,9 @@
<string name="main_install_anyway">Install anyway</string>
<string name="main_remove_runtime_title">Remove Linux runtime</string>
<string name="main_remove_runtime_text">This deletes the runtime, the Steam client inside it, and every game installed there.</string>
<string name="runtime_actions_title">Linux runtime</string>
<string name="runtime_actions_text">Repair reinstalls the runtime\'s system files and keeps Steam, your games and settings. Extras such as the desktop install again when you next use them. Remove deletes everything.</string>
<string name="runtime_repair">Repair runtime</string>
<string name="main_applied">Applied: %1$s</string>
<string name="android_app_open_failed_primary">Could not open %1$s on the primary screen</string>
<string name="android_app_open_failed_on">Could not open %1$s on %2$s</string>
@@ -1373,6 +1376,11 @@
<string name="session_runtime_catalog_unreachable">Could not reach the runtime catalog. Check the connection and press Play again.</string>
<string name="session_runtime_install_failed">The Linux runtime did not install. Check the runtime in Setup and press Play again.</string>
<string name="session_desktop_install_failed">The desktop did not install (%1$s). Check the connection and press Desktop again.</string>
<string name="session_package_resume_failed">An install that was interrupted could not be finished (%1$s). Check the connection and space, then try again.</string>
<plurals name="session_end_runtime_damaged">
<item quantity="one">The Linux runtime is damaged: %1$d library link points to a missing file (%2$s), usually after an install was interrupted. Repair keeps your games and settings.</item>
<item quantity="other">The Linux runtime is damaged: %1$d library links point to missing files (%2$s and others), usually after an install was interrupted. Repair keeps your games and settings.</item>
</plurals>
<string name="session_ended_named">%1$s stopped unexpectedly. Share the logs with a bug report, or try again.</string>
<string name="session_ended_program">The program stopped unexpectedly. Share the logs with a bug report, or try again.</string>
<string name="session_ended_desktop">The desktop stopped unexpectedly. Share the logs with a bug report, or try again.</string>
@@ -2,12 +2,16 @@ package com.droiddeck.launcher.runtime
import com.droiddeck.launcher.BuildConfig
import java.io.File
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class DesktopCatalogTest {
@get:Rule val temporary = TemporaryFolder()
@@ -30,4 +34,26 @@ class DesktopCatalogTest {
kwin.delete()
assertFalse(DesktopCatalog.desktopInstalled(root))
}
@Test fun anInterruptedInstallIsFoundAgainWithEverythingNeededToFinishIt() {
val root = temporary.root
val entry = DesktopCatalog.Entry(
DesktopCatalog.DESKTOP_ID, "Desktop", 1, "desktop-kde-r2", "tar",
"https://example.invalid/desktop-kde.tar.zst", "a".repeat(64), 846933224L, "", "", "",
)
assertTrue(DesktopCatalog.interrupted(root).isEmpty())
File(root, ".droiddeck-pkg-${entry.id}.installing").writeText(DesktopCatalog.entryJson(entry).toString())
File(root, ".droiddeck-pkg-emulators.installing").writeText("not json")
File(root, ".droiddeck-pkg-emulators").writeText("1")
val found = DesktopCatalog.interrupted(root).single()
assertEquals(entry.id, found.id)
assertEquals(entry.version, found.version)
assertEquals(entry.url, found.url)
assertEquals(entry.sha256, found.sha256)
assertEquals(entry.size, found.size)
assertEquals(entry.kind, found.kind)
}
}
@@ -0,0 +1,118 @@
package com.droiddeck.launcher.runtime
import java.io.ByteArrayOutputStream
import java.io.File
import java.nio.file.Files
import java.nio.file.Paths
import org.apache.commons.compress.archivers.tar.TarArchiveEntry
import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream
import org.apache.commons.compress.compressors.zstandard.ZstdCompressorOutputStream
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
@RunWith(RobolectricTestRunner::class)
class PackageExtractTest {
private val dir = Files.createTempDirectory("package-extract").toFile()
private val root = File(dir, "linuxfs").apply { mkdirs() }
private val context = RuntimeEnvironment.getApplication()
@After fun tearDown() { dir.deleteRecursively() }
private sealed class Item(val name: String) {
class Link(name: String, val target: String) : Item(name)
class Data(name: String, val text: String) : Item(name)
}
private fun archive(vararg items: Item): File {
val tar = ByteArrayOutputStream()
TarArchiveOutputStream(tar).use { out ->
for (item in items) {
when (item) {
is Item.Link -> out.putArchiveEntry(TarArchiveEntry(item.name, TarArchiveEntry.LF_SYMLINK).apply { linkName = item.target })
is Item.Data -> {
val bytes = item.text.toByteArray()
out.putArchiveEntry(TarArchiveEntry(item.name).apply { size = bytes.size.toLong(); mode = 0b111_101_101 })
out.write(bytes)
}
}
out.closeArchiveEntry()
}
}
return File(dir, "pkg-${System.nanoTime()}.tar.zst").apply {
ZstdCompressorOutputStream(outputStream()).use { it.write(tar.toByteArray()) }
}
}
private fun lib(name: String) = File(root, "usr/lib/$name")
private fun installOld() {
lib("").mkdirs()
lib("libvulkan.so.1.4.357").writeText("old")
Files.createSymbolicLink(lib("libvulkan.so.1").toPath(), Paths.get("libvulkan.so.1.4.357"))
}
@Test fun linksListedBeforeTheirTargetsResolveAfterExtraction() {
installOld()
val pkg = archive(
Item.Link("usr/lib/libvulkan.so.1", "libvulkan.so.1.4.363"),
Item.Data("usr/lib/libvulkan.so.1.4.363", "new"),
)
assertTrue(LinuxRuntimeInstaller.extract(context, pkg, root, null))
assertEquals("new", lib("libvulkan.so.1").readText())
assertEquals(emptyList<String>(), RuntimeHealth.brokenLibraryLinks(root))
assertFalse(lib("").list()!!.any { it.startsWith(".dd-part-") })
}
@Test fun anExtractionStoppedBetweenALinkAndItsTargetLeavesTheOldLinkWorking() {
installOld()
val pkg = archive(
Item.Link("usr/lib/libvulkan.so.1", "libvulkan.so.1.4.363"),
Item.Data("usr/lib/libharfbuzz.so.0", "harfbuzz"),
Item.Data("usr/lib/libharfbuzz.so.0/cannot-be-written", "stops here"),
Item.Data("usr/lib/libvulkan.so.1.4.363", "new"),
)
assertFalse(LinuxRuntimeInstaller.extract(context, pkg, root, null))
assertEquals("old", lib("libvulkan.so.1").readText())
assertFalse(lib("libvulkan.so.1.4.363").exists())
assertEquals(emptyList<String>(), RuntimeHealth.brokenLibraryLinks(root))
}
@Test fun aFileReplacesAnExistingLinkInsteadOfWritingThroughIt() {
installOld()
val pkg = archive(Item.Data("usr/lib/libvulkan.so.1", "now a file"))
assertTrue(LinuxRuntimeInstaller.extract(context, pkg, root, null))
assertFalse(Files.isSymbolicLink(lib("libvulkan.so.1").toPath()))
assertEquals("now a file", lib("libvulkan.so.1").readText())
assertEquals("old", lib("libvulkan.so.1.4.357").readText())
assertTrue(lib("libvulkan.so.1").canExecute())
}
@Test fun theLastEntryForAPathWins() {
lib("").mkdirs()
lib("target").writeText("t")
val pkg = archive(
Item.Link("usr/lib/libdup.so", "target"),
Item.Data("usr/lib/libdup.so", "file"),
Item.Data("usr/lib/libother.so", "file"),
Item.Link("usr/lib/libother.so", "target"),
)
assertTrue(LinuxRuntimeInstaller.extract(context, pkg, root, null))
assertFalse(Files.isSymbolicLink(lib("libdup.so").toPath()))
assertEquals("file", lib("libdup.so").readText())
assertTrue(Files.isSymbolicLink(lib("libother.so").toPath()))
}
}
@@ -0,0 +1,36 @@
package com.droiddeck.launcher.runtime
import java.io.File
import java.nio.file.Files
import java.nio.file.Paths
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
class RuntimeHealthTest {
@get:Rule val temporary = TemporaryFolder()
private fun link(root: File, name: String, target: String) =
Files.createSymbolicLink(File(root, "usr/lib/$name").toPath(), Paths.get(target))
@Test fun reportsOnlyLibraryLinksWhoseTargetIsMissingInsideTheRuntime() {
val root = temporary.root
val lib = File(root, "usr/lib").apply { mkdirs() }
File(lib, "libvulkan.so.1.4.357").writeText("lib")
File(root, "etc").mkdirs()
File(root, "etc/ld.conf").writeText("conf")
link(root, "libvulkan.so.1", "libvulkan.so.1.4.357")
link(root, "libvulkan.so", "libvulkan.so.1")
link(root, "libabsolute.so", "/etc/ld.conf")
link(root, "libharfbuzz.so.0", "libharfbuzz.so.0.61460.0")
link(root, "libchain.so", "libharfbuzz.so.0")
link(root, "libhost.so", File(lib, "libvulkan.so.1.4.357").absolutePath)
assertEquals(listOf("libchain.so", "libharfbuzz.so.0", "libhost.so"), RuntimeHealth.brokenLibraryLinks(root))
}
@Test fun aMissingLibraryFolderIsNotReportedAsDamage() {
assertEquals(emptyList<String>(), RuntimeHealth.brokenLibraryLinks(temporary.root))
}
}