feat: import ARM64 AppImages from storage and run them from the Desktop page

This commit is contained in:
xXJsonDeruloXx committed 2026-09-29 13:33:29 -04:00
1 parent 34640e83cb
commit b0e02124d8
14 files changed
+476 -56

No files matched your search

@@ -172,6 +172,9 @@ class MainActivity : ComponentActivity() {
private var addedGamesArt by mutableStateOf(true)
@Volatile private var artFetchRunning = false
private var addedGames by mutableStateOf<List<com.droiddeck.launcher.ui.AddedGameRow>>(emptyList())
private val pickAppImage = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { com.droiddeck.launcher.store.AppImageState.import(this, it) }
}
private val pickRomsDir = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedPath(r.data)?.let { path ->
SessionPrefs.setRomsDir(this, path)
@@ -327,6 +330,17 @@ class MainActivity : ComponentActivity() {
},
onEmulator = { e -> launchProgram(e.program) },
// A Flatpak app from the store, full screen under gamescope like an emulator.
onImportAppImage = {
pickAppImage.launch(InAppFilePicker.buildIntent(this, listOf("appimage"), "Choose an AppImage"))
},
// An imported AppImage, full screen under gamescope like an emulator.
onAppImage = { dir, name ->
Library.flatpakNames[dir] = name
startActivity(Intent(this, SessionActivity::class.java)
.putExtra(SessionService.EXTRA_MODE, SessionService.MODE_RUN)
.putExtra(SessionService.EXTRA_PROGRAM, com.droiddeck.launcher.runtime.AppImageManager.LAUNCHER)
.putExtra(SessionService.EXTRA_PROGRAM_ARGS, arrayOf(dir)))
},
onFlatpakApp = { id, name ->
Library.flatpakNames[id] = name
startActivity(Intent(this, SessionActivity::class.java)
@@ -164,11 +164,11 @@ object Library {
/** The emulator's name for a program path from the rail ("ARMSX2"), or null. */
fun nameForProgram(program: String?): String? =
if (program == com.droiddeck.launcher.runtime.FlatpakManager.LAUNCHER) {
if (program == com.droiddeck.launcher.runtime.FlatpakManager.LAUNCHER || program == com.droiddeck.launcher.runtime.AppImageManager.LAUNCHER) {
com.droiddeck.launcher.session.SessionState.programArgs.firstOrNull()?.let { flatpakNames[it] ?: it.substringAfterLast('.') }
} else specs.firstOrNull { it.program == program }?.name
/** Flatpak apps' names by id, as they were launched: the session only knows the id. */
/** Flatpak apps' and AppImages' names by id or directory, as launched: the session only knows that. */
val flatpakNames = java.util.concurrent.ConcurrentHashMap<String, String>()
/**
@@ -0,0 +1,181 @@
package com.droiddeck.launcher.runtime
import android.content.Context
import android.os.StatFs
import android.util.Log
import com.droiddeck.launcher.core.FileUtils
import java.io.File
import java.io.RandomAccessFile
/**
* AppImages the user brings: picked from storage, checked, and extracted once into the runtime at
* /opt/appimages/user/<id>/app. proot has no FUSE to mount an image, and extracting it at every
* start would cost its whole size in /tmp each time. Each one gets a menu entry on the Linux
* desktop, and the front end starts it under gamescope through bannerlator-appimage-run.
*
* <id>/name holds the name to show, <id>/icon.png the icon when the image has a PNG one.
*/
object AppImageManager {
private const val TAG = "AppImageManager"
const val GUEST_DIR = "/opt/appimages/user"
const val LAUNCHER = "/usr/local/bin/bannerlator-appimage-run"
private const val ELF_AARCH64 = 183
private const val ELF_X86_64 = 62
class Item(val id: String, val name: String, val icon: File?, val comment: String?) {
val guestDir: String get() = "$GUEST_DIR/$id"
}
private fun root(context: Context) = File(LinuxRuntime.rootDir(context), GUEST_DIR.substring(1))
fun list(context: Context): List<Item> =
root(context).listFiles()?.filter { File(it, "app/AppRun").exists() || Files.isLink(File(it, "app/AppRun")) }
?.map { dir ->
Item(
dir.name,
FileUtils.readString(File(dir, "name"))?.trim()?.takeIf { it.isNotEmpty() } ?: dir.name,
File(dir, "icon.png").takeIf { it.isFile },
FileUtils.readString(File(dir, "comment"))?.trim()?.takeIf { it.isNotEmpty() },
)
}?.sortedBy { it.name.lowercase() } ?: emptyList()
/**
* What is wrong with [file] as an AppImage for this device, or null when it can be imported.
* An AppImage is an ELF program (its runtime) with "AI" and the format version at byte 8.
*/
internal fun problem(file: File): String? {
if (!file.isFile) return "The file is gone"
val head = ByteArray(20)
try {
RandomAccessFile(file, "r").use { if (it.read(head) < head.size) return "The file is too small to be an AppImage" }
} catch (e: Exception) {
return "The file cannot be read: ${e.message}"
}
if (head[0] != 0x7f.toByte() || head[1] != 'E'.code.toByte() || head[2] != 'L'.code.toByte() || head[3] != 'F'.code.toByte()) {
return "This is not an AppImage (it is not a Linux program)"
}
val machine = (head[18].toInt() and 0xff) or ((head[19].toInt() and 0xff) shl 8)
return when {
machine == ELF_X86_64 -> "This AppImage is built for x86_64 PCs. DroidDeck runs ARM64 (aarch64) AppImages - look for an \"aarch64\" or \"arm64\" download."
machine != ELF_AARCH64 -> "This AppImage is not built for ARM64 (aarch64)"
head[8] != 'A'.code.toByte() || head[9] != 'I'.code.toByte() -> "This is an ARM64 program but not an AppImage"
head[10].toInt() != 2 -> "Only type 2 AppImages can be imported (this one is type ${head[10].toInt()})"
else -> null
}
}
/** A directory name from the image's file name: "Some_App-1.2-aarch64.AppImage" -> "some-app-1-2-aarch64". */
internal fun idFor(fileName: String, taken: Set<String>): String {
val base = fileName.removeSuffix(".AppImage").removeSuffix(".appimage").lowercase()
.replace(Regex("[^a-z0-9]+"), "-").trim('-').take(48).ifEmpty { "appimage" }
var id = base
var n = 2
while (id in taken) id = "$base-${n++}"
return id
}
/** Imports [file]; null on success, else what went wrong. [onProgress] gets a stage line. */
fun import(context: Context, file: File, onProgress: (String) -> Unit): String? {
if (!LinuxRuntime.isInstalled(context)) return "Install the Linux runtime first"
problem(file)?.let { return it }
val base = root(context).apply { mkdirs() }
// Room for the image while it is extracted, and the extracted tree (about the same again).
if (StatFs(base.path).availableBytes < file.length() * 3) {
return "Not enough free space: importing needs about ${FileUtils.sizeToString(file.length() * 3)}"
}
val id = idFor(file.name, base.list()?.toSet() ?: emptySet())
val dir = File(base, id)
val image = File(dir, "image.AppImage")
try {
dir.mkdirs()
// Copied in, not run where it lies: shared storage is mounted noexec, and proot's loader
// maps a program executable.
onProgress("Copying ${file.name}")
file.inputStream().use { input -> image.outputStream().use { FileUtils.copy(input, it) } }
image.setExecutable(true, false)
onProgress("Extracting ${file.name}")
val guestDir = "$GUEST_DIR/$id"
val out = StringBuilder()
val status = GuestCommand.run(context, listOf(
"/bin/bash", "-c",
"cd \"$1\" && ./image.AppImage --appimage-extract >/dev/null && mv squashfs-root app",
"extract", guestDir,
), logName = "appimage-import") { line -> if (out.length < 2000) out.appendLine(line) }
if (status != 0 || !File(dir, "app").isDirectory) {
FileUtils.delete(dir)
return "The AppImage could not be extracted" + (out.lines().lastOrNull { it.isNotBlank() }?.let { ": $it" } ?: "")
}
image.delete()
describe(dir, file)
writeMenuEntry(context, dir)
return null
} catch (e: Exception) {
Log.e(TAG, "import ${file.name}", e)
FileUtils.delete(dir)
return e.message ?: "Import failed"
} finally {
image.delete()
}
}
/** Name, comment and icon from the image's own desktop entry; the file name when it has none. */
private fun describe(dir: File, source: File) {
val app = File(dir, "app")
val entry = app.listFiles { f -> f.isFile && f.name.endsWith(".desktop") }?.firstOrNull()
val text = entry?.let { FileUtils.readString(it) }
fun key(k: String) = text?.let { desktopKey(it, k) }
val name = key("Name") ?: source.name.substringBeforeLast('.')
FileUtils.writeString(File(dir, "name"), name)
key("Comment")?.let { FileUtils.writeString(File(dir, "comment"), it) }
key("Categories")?.let { FileUtils.writeString(File(dir, "categories"), it) }
// The icon the entry names, beside it or anywhere in the image's icon theme, else .DirIcon.
val iconName = key("Icon")
val candidates = listOfNotNull(
iconName?.let { File(app, "$it.png") },
iconName?.let { n -> app.walkTopDown().maxDepth(8).filter { it.isFile && it.name == "$n.png" }.maxByOrNull { it.length() } },
File(app, ".DirIcon"),
)
candidates.firstOrNull { it.isFile && isPng(it) }?.copyTo(File(dir, "icon.png"), overwrite = true)
}
private fun isPng(f: File): Boolean = try {
f.inputStream().use { val b = ByteArray(4); it.read(b) == 4 && b[1] == 'P'.code.toByte() && b[2] == 'N'.code.toByte() && b[3] == 'G'.code.toByte() }
} catch (e: Exception) { false }
/** A key of a desktop entry's main group, unlocalised. */
internal fun desktopKey(desktop: String, key: String): String? {
var inMain = false
for (line in desktop.lineSequence()) {
val t = line.trim()
if (t.startsWith("[")) { inMain = t == "[Desktop Entry]"; continue }
if (inMain && t.startsWith("$key=")) return t.substringAfter('=').trim().takeIf { it.isNotEmpty() }
}
return null
}
private fun menuEntry(context: Context, id: String) =
// A system directory, not ~/.local/share/applications: the desktop copies game entries from
// here into the user's with a GPU launcher (droiddeck-desktop-gpu) while it runs on pixman.
File(LinuxRuntime.rootDir(context), "usr/local/share/applications/droiddeck-appimage-$id.desktop")
/** The Linux desktop's menu entry: the desktop's own Exec rules (and droiddeck-gpu) apply to it. */
private fun writeMenuEntry(context: Context, dir: File) {
val id = dir.name
val name = FileUtils.readString(File(dir, "name"))?.trim() ?: id
val categories = FileUtils.readString(File(dir, "categories"))?.trim()?.takeIf { it.isNotEmpty() } ?: "Utility;"
val icon = if (File(dir, "icon.png").isFile) "$GUEST_DIR/$id/icon.png" else "application-x-executable"
FileUtils.writeString(menuEntry(context, id),
"[Desktop Entry]\nType=Application\nName=$name\nExec=$LAUNCHER $GUEST_DIR/$id %U\nIcon=$icon\n" +
"Terminal=false\nCategories=$categories\nX-DroidDeck-AppImage=$id\n")
}
fun remove(context: Context, id: String) {
if (id.isEmpty() || '/' in id || id.startsWith(".")) return
FileUtils.delete(File(root(context), id))
menuEntry(context, id).delete()
}
private object Files {
fun isLink(f: File) = java.nio.file.Files.isSymbolicLink(f.toPath())
}
}
@@ -292,6 +292,8 @@ object BwrapSpawner {
*/
private fun browserSandboxes(env: MutableMap<String, String>) {
env.putIfAbsent("ZYPAK_ZYGOTE_STRATEGY_SPAWN", "0")
// Qt WebEngine is Chromium without zypak: it sets up its own namespace sandbox.
env.putIfAbsent("QTWEBENGINE_DISABLE_SANDBOX", "1")
for (name in listOf("MOZ_DISABLE_CONTENT_SANDBOX", "MOZ_DISABLE_GMP_SANDBOX", "MOZ_DISABLE_RDD_SANDBOX",
"MOZ_DISABLE_SOCKET_PROCESS_SANDBOX", "MOZ_DISABLE_UTILITY_SANDBOX")) {
env.putIfAbsent(name, "1")
@@ -372,6 +374,9 @@ object BwrapSpawner {
}
val process = builder.start()
val pid = HostProcess.pidOf(process)
// A sandbox lives exactly as long as its bwrap stand-in, which ends it; a session starting
// meanwhile (OrphanReaper) must not take it - an install's extra-data step, say.
com.droiddeck.launcher.session.OrphanReaper.keep(pid)
Log.i(TAG, "sandbox $id pid $pid: ${plan.argv.joinToString(" ")} (${plan.binds.size} binds)")
synchronized(out) { frame(out, 'P', pid.toString().toByteArray()) }
// The stand-in going away (Flatpak killed, the session over) ends the sandbox.
@@ -400,6 +405,7 @@ object BwrapSpawner {
}
}
val status = process.waitFor()
com.droiddeck.launcher.session.OrphanReaper.release(pid)
Log.i(TAG, "sandbox $id exited $status")
runCatching { synchronized(out) { frame(out, 'X', status.toString().toByteArray()) } }
}
@@ -1,13 +1,8 @@
package com.droiddeck.launcher.runtime
import android.content.Context
import android.os.Environment
import android.util.Log
import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.core.HostProcess
import com.droiddeck.launcher.session.OrphanReaper
import com.droiddeck.launcher.session.SessionFiles
import com.droiddeck.launcher.session.SessionPrefs
import org.json.JSONObject
import java.io.File
@@ -91,55 +86,10 @@ object FlatpakManager {
?.maxByOrNull { it.first }?.second
}
/**
* Runs [argv] in the runtime and hands each output line to [onLine]; returns the exit status.
* [fakeRoot] is for the package tools, which refuse any uid but 0.
*/
/** Runs [argv] in the runtime, its output also in Download/DroidDeck/flatpak-<verb>.log. */
private fun runGuest(context: Context, argv: List<String>, fakeRoot: Boolean, onLine: (String) -> Unit): Int {
// Everything the command says, beside the session logs (Download/DroidDeck), so a store
// problem can be handed over like a session's: flatpak-setup.log, flatpak-install.log...
val log = try {
val name = "flatpak-" + (argv.getOrNull(2)?.takeIf { argv.getOrNull(1) == HELPER } ?: "setup") + ".log"
File(LinuxRuntime.debugLogDir().apply { mkdirs() }, name).printWriter()
} catch (e: Exception) { null }
log?.println("== ${java.util.Date()} ${argv.joinToString(" ")}")
try {
return runGuestLogged(context, argv, fakeRoot) { line -> log?.println(line); log?.flush(); onLine(line) }
} finally {
log?.close()
}
}
private fun runGuestLogged(context: Context, argv: List<String>, fakeRoot: Boolean, onLine: (String) -> Unit): Int {
val root = LinuxRuntime.rootDir(context)
LinuxRuntime.writeAccounts(context)
SessionFiles.stage(context, root)
val runtimeDir = File(context.filesDir, ".flatpak-rt").apply { mkdirs() }
val cmd = LinuxRuntime.prootPrefix(context, root, "/root", fakeRoot)
LinuxRuntime.binds(context, null, runtimeDir, Environment.getExternalStorageDirectory(), null)
.forEach { cmd.add("-b"); cmd.add(it) }
cmd += listOf(
"/usr/bin/env", "-i", "HOME=/root", "USER=root", "LANG=C.UTF-8",
"PATH=/usr/local/bin:/usr/bin:/bin", "XDG_RUNTIME_DIR=${runtimeDir.path}",
"XDG_DATA_HOME=/root/.local/share", "FLATPAK_BWRAP=$BWRAP",
)
cmd += argv
val builder = ProcessBuilder(cmd).directory(root).redirectErrorStream(true)
builder.environment().apply {
put("PROOT_LOADER", LinuxRuntime.prootLoader(context).path)
put("PROOT_TMP_DIR", context.cacheDir.path)
if (SessionPrefs.prootNoSeccomp(context)) put("PROOT_NO_SECCOMP", "1")
LinuxRuntime.prootLibraryPath(context).takeIf { it.isNotEmpty() }?.let { put("LD_LIBRARY_PATH", it) }
}
val process = builder.start()
val pid = HostProcess.pidOf(process)
OrphanReaper.keep(pid)
try {
process.inputStream.bufferedReader().useLines { lines -> lines.forEach(onLine) }
return process.waitFor()
} finally {
OrphanReaper.release(pid)
}
val name = "flatpak-" + (argv.getOrNull(2)?.takeIf { argv.getOrNull(1) == HELPER } ?: "setup")
return GuestCommand.run(context, argv, fakeRoot, name, onLine)
}
private inline fun <T> exclusive(what: String, block: () -> T): T? {
@@ -0,0 +1,66 @@
package com.droiddeck.launcher.runtime
import android.content.Context
import android.os.Environment
import com.droiddeck.launcher.core.HostProcess
import com.droiddeck.launcher.session.OrphanReaper
import com.droiddeck.launcher.session.SessionFiles
import com.droiddeck.launcher.session.SessionPrefs
import java.io.File
/**
* One command in the Linux runtime outside any session - the store's Flatpak work, an AppImage
* import - in a proot of its own. A session starting meanwhile leaves it running (OrphanReaper).
*/
object GuestCommand {
/**
* Runs [argv] and hands each output line to [onLine]; returns the exit status. [fakeRoot] is
* for the package tools, which refuse any uid but 0. With [logName], everything the command
* says also goes to Download/DroidDeck/<logName>.log beside the session logs, so a problem can
* be handed over like a session's.
*/
fun run(context: Context, argv: List<String>, fakeRoot: Boolean = false, logName: String? = null,
onLine: (String) -> Unit): Int {
val log = logName?.let {
try { File(LinuxRuntime.debugLogDir().apply { mkdirs() }, "$it.log").printWriter() } catch (e: Exception) { null }
}
log?.println("== ${java.util.Date()} ${argv.joinToString(" ")}")
try {
return runLogged(context, argv, fakeRoot) { line -> log?.println(line); log?.flush(); onLine(line) }
} finally {
log?.close()
}
}
private fun runLogged(context: Context, argv: List<String>, fakeRoot: Boolean, onLine: (String) -> Unit): Int {
val root = LinuxRuntime.rootDir(context)
LinuxRuntime.writeAccounts(context)
SessionFiles.stage(context, root)
val runtimeDir = File(context.filesDir, ".flatpak-rt").apply { mkdirs() }
val cmd = LinuxRuntime.prootPrefix(context, root, "/root", fakeRoot)
LinuxRuntime.binds(context, null, runtimeDir, Environment.getExternalStorageDirectory(), null)
.forEach { cmd.add("-b"); cmd.add(it) }
cmd += listOf(
"/usr/bin/env", "-i", "HOME=/root", "USER=root", "LANG=C.UTF-8",
"PATH=/usr/local/bin:/usr/bin:/bin", "XDG_RUNTIME_DIR=${runtimeDir.path}",
"XDG_DATA_HOME=/root/.local/share", "FLATPAK_BWRAP=${FlatpakManager.BWRAP}",
)
cmd += argv
val builder = ProcessBuilder(cmd).directory(root).redirectErrorStream(true)
builder.environment().apply {
put("PROOT_LOADER", LinuxRuntime.prootLoader(context).path)
put("PROOT_TMP_DIR", context.cacheDir.path)
if (SessionPrefs.prootNoSeccomp(context)) put("PROOT_NO_SECCOMP", "1")
LinuxRuntime.prootLibraryPath(context).takeIf { it.isNotEmpty() }?.let { put("LD_LIBRARY_PATH", it) }
}
val process = builder.start()
val pid = HostProcess.pidOf(process)
OrphanReaper.keep(pid)
try {
process.inputStream.bufferedReader().useLines { lines -> lines.forEach(onLine) }
return process.waitFor()
} finally {
OrphanReaper.release(pid)
}
}
}
@@ -47,6 +47,8 @@ object SessionFiles {
"usr/local/bin/bannerlator-flatpak" to "usr/local/bin/bannerlator-flatpak",
"usr/local/bin/bannerlator-flatpak-setup" to "usr/local/bin/bannerlator-flatpak-setup",
"usr/local/bin/bannerlator-flatpak-run" to "usr/local/bin/bannerlator-flatpak-run",
// The user's own AppImages, extracted at import (AppImageManager).
"usr/local/bin/bannerlator-appimage-run" to "usr/local/bin/bannerlator-appimage-run",
// The SteamOS helpers the client calls in Deck mode: the two Armada found it needs, plus
// the three under /usr/bin, all no-ops that answer "nothing to do" (see each file).
"usr/bin/steamos-update" to "usr/bin/steamos-update",
@@ -0,0 +1,63 @@
package com.droiddeck.launcher.store
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.widget.Toast
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.droiddeck.launcher.runtime.AppImageManager
import java.io.File
/** The imported AppImages and the import in progress, for the Desktop page. */
object AppImageState {
private val main = Handler(Looper.getMainLooper())
var items by mutableStateOf<List<AppImageManager.Item>>(emptyList())
private set
/** The file being imported and what the import is doing; null when idle. */
var importing by mutableStateOf<String?>(null)
private set
var stage by mutableStateOf<String?>(null)
private set
/** Why the last import was refused, shown on the page until the next one. */
var lastError by mutableStateOf<String?>(null)
private set
fun refresh(context: Context) {
val app = context.applicationContext
Thread({
val list = AppImageManager.list(app)
main.post { items = list }
}, "appimage-refresh").start()
}
fun import(context: Context, file: File) {
if (importing != null) return
val app = context.applicationContext
importing = file.name; stage = "Starting…"; lastError = null
Thread({
val problem = try {
AppImageManager.import(app, file) { s -> main.post { stage = s } }
} catch (e: Exception) {
e.message ?: e.toString()
}
val list = AppImageManager.list(app)
main.post {
importing = null; stage = null; items = list
lastError = problem?.let { "${file.name}: $it" }
if (problem == null) Toast.makeText(app, "${file.name} imported", Toast.LENGTH_SHORT).show()
}
}, "appimage-import").start()
}
fun remove(context: Context, id: String) {
val app = context.applicationContext
Thread({
AppImageManager.remove(app, id)
val list = AppImageManager.list(app)
main.post { items = list }
}, "appimage-remove").start()
}
}
@@ -198,6 +198,7 @@ private fun Content(
Rise(4) { EmulatorGrid(installed, first = true, onSelect = onSelect) }
}
Rise(5) { InstalledAppsGrid(a) }
Rise(5) { AppImagesSection(a, s.ready) }
if (available.isNotEmpty()) {
Rise(5) { SectionTitle("Available to install", available.size.toString()) }
Rise(6) { EmulatorGrid(available, first = installed.isEmpty(), onSelect = onSelect) }
@@ -124,6 +124,9 @@ class FrontEndActions(
val onEmulator: (Library.Emulator) -> Unit,
/** A Flatpak app by id and name, from the Store. */
val onFlatpakApp: (String, String) -> Unit = { _, _ -> },
/** Pick an AppImage to import; open an imported one by its directory and name. */
val onImportAppImage: () -> Unit = {},
val onAppImage: (String, String) -> Unit = { _, _ -> },
val onRom: (Library.Rom) -> Unit,
val onResume: () -> Unit,
val onSteamSettings: () -> Unit,
@@ -495,3 +495,62 @@ internal fun InstalledAppsGrid(a: FrontEndActions) {
}
}
}
/**
* The user's own AppImages on the Desktop page: an Add button, each one to open, and Remove
* (pressed twice, like the store's). The picker, the check and the extraction live elsewhere
* (MainActivity, AppImageManager); this only shows them.
*/
@Composable
internal fun AppImagesSection(a: FrontEndActions, runtimeReady: Boolean) {
val ctx = LocalContext.current
LaunchedEffect(Unit) { com.droiddeck.launcher.store.AppImageState.refresh(ctx) }
val state = com.droiddeck.launcher.store.AppImageState
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
SectionTitle("AppImages", state.items.size.takeIf { it > 0 }?.toString())
Actions {
SecondaryButton(if (state.importing != null) "Importing…" else "Add AppImage", enabled = runtimeReady && state.importing == null) { a.onImportAppImage() }
if (!runtimeReady) ActionChip("Runtime required", ok = false)
}
state.importing?.let { name ->
Text("$name · ${state.stage ?: "Starting…"}", fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
LinearProgressIndicator(modifier = Modifier.fillMaxWidth().height(4.dp))
}
state.lastError?.let { Note(it) }
if (state.items.isEmpty() && state.importing == null && state.lastError == null) {
Note("Add an ARM64 (aarch64) AppImage from your storage. It is unpacked into the Linux runtime once, then opens full screen from here or from the desktop's menu.")
}
state.items.forEach { item ->
key(item.id) {
var confirm by remember(item.id) { mutableStateOf(false) }
LaunchedEffect(confirm) { if (confirm) { kotlinx.coroutines.delay(4000); confirm = false } }
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14).padding(end = 12.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.weight(1f).paneItem("tile:appimage:${item.id}").clip(Shape14)
.background(if (hot) pal.signal.copy(alpha = 0.10f) else Color.Transparent)
.border(2.dp, if (hot) pal.signal else Color.Transparent, Shape14)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button) { a.onAppImage(item.guestDir, item.name) }
.padding(horizontal = 12.dp, vertical = 10.dp),
) {
AppIcon(item.icon, item.name, 44)
Column(modifier = Modifier.weight(1f)) {
Text(item.name, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(item.comment ?: "AppImage", fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
SecondaryButton(if (confirm) "Press again to remove" else "Remove", compact = true) {
if (confirm) { confirm = false; com.droiddeck.launcher.store.AppImageState.remove(ctx, item.id) } else confirm = true
}
}
}
}
}
}
@@ -0,0 +1,51 @@
package com.droiddeck.launcher.runtime
import java.io.File
import java.nio.file.Files
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class AppImageManagerTest {
private lateinit var dir: File
@Before fun setUp() { dir = Files.createTempDirectory("appimage").toFile() }
@After fun tearDown() { dir.deleteRecursively() }
/** An ELF header with the AppImage magic at byte 8 and [machine] at byte 18. */
private fun image(machine: Int, type: Int = 2, magic: Boolean = true): File {
val b = ByteArray(64)
b[0] = 0x7f; b[1] = 'E'.code.toByte(); b[2] = 'L'.code.toByte(); b[3] = 'F'.code.toByte()
if (magic) { b[8] = 'A'.code.toByte(); b[9] = 'I'.code.toByte(); b[10] = type.toByte() }
b[18] = (machine and 0xff).toByte(); b[19] = (machine shr 8).toByte()
return File(dir, "x.AppImage").apply { writeBytes(b) }
}
@Test fun acceptsAnArm64TypeTwoImage() = assertNull(AppImageManager.problem(image(183)))
@Test fun refusesX86WithAHint() = assertTrue(AppImageManager.problem(image(62))!!.contains("x86_64"))
@Test fun refusesTypeOne() = assertTrue(AppImageManager.problem(image(183, type = 1))!!.contains("type 2"))
@Test fun refusesAPlainProgram() = assertTrue(AppImageManager.problem(image(183, magic = false))!!.contains("not an AppImage"))
@Test fun refusesSomethingElse() {
val f = File(dir, "notes.AppImage").apply { writeText("hello, this is not a program at all") }
assertTrue(AppImageManager.problem(f)!!.contains("not an AppImage"))
}
@Test fun idsAreTidyAndUnique() {
assertEquals("some-app-1-2-aarch64", AppImageManager.idFor("Some_App-1.2-aarch64.AppImage", emptySet()))
assertEquals("some-app-2", AppImageManager.idFor("Some App.AppImage", setOf("some-app")))
}
@Test fun readsTheMainDesktopGroup() {
val entry = "[Desktop Entry]\nName=Duck\nName[de]=Ente\nIcon=duck\n[Desktop Action x]\nName=Other\n"
assertEquals("Duck", AppImageManager.desktopKey(entry, "Name"))
assertEquals("duck", AppImageManager.desktopKey(entry, "Icon"))
assertNull(AppImageManager.desktopKey(entry, "Comment"))
}
}
+3 -1
View File
@@ -12,6 +12,8 @@ import re
HOME = os.environ.get("HOME", "/root")
SYSTEM_DIR = "/usr/share/applications"
# The user's own AppImages (the front end's Add AppImage).
LOCAL_DIR = "/usr/local/share/applications"
# Flathub's games, as Flatpak exports them; the copies here win over these as they do over the system's.
FLATPAK_DIR = os.path.join(HOME, ".local/share/flatpak/exports/share/applications")
USER_DIR = os.path.join(HOME, ".local/share/applications")
@@ -102,7 +104,7 @@ def main():
on = enabled()
os.makedirs(USER_DIR, exist_ok=True)
written = set()
sources = [d for d in (SYSTEM_DIR, FLATPAK_DIR) if os.path.isdir(d)] if on else []
sources = [d for d in (SYSTEM_DIR, LOCAL_DIR, FLATPAK_DIR) if os.path.isdir(d)] if on else []
for source in sources:
for name in sorted(os.listdir(source)):
if not name.endswith(".desktop") or SKIP.match(name):
@@ -0,0 +1,22 @@
#!/bin/bash
# An AppImage imported from the front end (AppImageManager), extracted once at import into
# /opt/appimages/user/<id>/app
# and started from there: proot has no FUSE to mount the image, and extracting it at every
# start (APPIMAGE_EXTRACT_AND_RUN) would cost the whole image in /tmp each time.
# bannerlator-appimage-run /opt/appimages/user/<id> [args...]
set -u
dir=${1:?usage: bannerlator-appimage-run <imported AppImage directory> [args...]}
shift
export APPDIR=$dir/app
# APPIMAGE is left unset on purpose: with it an app believes it runs from an image file and offers
# to add itself to the menu (DuckStation) or update the file in place - DroidDeck already does the
# one, and there is no image file for the other.
unset APPIMAGE
export ARGV0=$(cat "$dir/name" 2>/dev/null || basename "$dir")
if [ ! -x "$APPDIR/AppRun" ]; then
echo "bannerlator-appimage-run: $APPDIR/AppRun is missing; import the AppImage again" >&2
exit 127
fi
echo "== appimage $ARGV0 ($APPDIR)"
cd "$APPDIR" || exit 1
exec "$APPDIR/AppRun" "$@"