mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-18 21:27:16 -04:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ac9c7a910 | ||
|
|
b794f610f1 |
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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user