diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index 90d21864d..ef30bfb5e 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -172,6 +172,9 @@ class MainActivity : ComponentActivity() { private var addedGamesArt by mutableStateOf(true) @Volatile private var artFetchRunning = false private var addedGames by mutableStateOf>(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) diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt b/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt index a43260a76..89a4ded34 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt @@ -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() /** diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/AppImageManager.kt b/app/src/main/java/com/droiddeck/launcher/runtime/AppImageManager.kt new file mode 100644 index 000000000..a11df690e --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/runtime/AppImageManager.kt @@ -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//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. + * + * /name holds the name to show, /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 = + 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 { + 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()) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/BwrapSpawner.kt b/app/src/main/java/com/droiddeck/launcher/runtime/BwrapSpawner.kt index c34e32556..f4b148144 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/BwrapSpawner.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/BwrapSpawner.kt @@ -292,6 +292,8 @@ object BwrapSpawner { */ private fun browserSandboxes(env: MutableMap) { 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()) } } } diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/FlatpakManager.kt b/app/src/main/java/com/droiddeck/launcher/runtime/FlatpakManager.kt index c83102647..0960d95e9 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/FlatpakManager.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/FlatpakManager.kt @@ -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-.log. */ private fun runGuest(context: Context, argv: List, 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, 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 exclusive(what: String, block: () -> T): T? { diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt b/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt new file mode 100644 index 000000000..7d3c6ecd3 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt @@ -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/.log beside the session logs, so a problem can + * be handed over like a session's. + */ + fun run(context: Context, argv: List, 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, 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) + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt index 6dc663f7d..ed23b57f3 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt @@ -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", diff --git a/app/src/main/java/com/droiddeck/launcher/store/AppImageState.kt b/app/src/main/java/com/droiddeck/launcher/store/AppImageState.kt new file mode 100644 index 000000000..91a7ac58c --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/store/AppImageState.kt @@ -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>(emptyList()) + private set + /** The file being imported and what the import is doing; null when idle. */ + var importing by mutableStateOf(null) + private set + var stage by mutableStateOf(null) + private set + /** Why the last import was refused, shown on the page until the next one. */ + var lastError by mutableStateOf(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() + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt index ef9e1e288..3c1574828 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt @@ -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) } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt index 3f8d33d33..98176bd04 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt @@ -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, diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StorePage.kt b/app/src/main/java/com/droiddeck/launcher/ui/StorePage.kt index d2f2c52e5..da057cb72 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StorePage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StorePage.kt @@ -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 + } + } + } + } + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/runtime/AppImageManagerTest.kt b/app/src/test/java/com/droiddeck/launcher/runtime/AppImageManagerTest.kt new file mode 100644 index 000000000..1cd246ab4 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/runtime/AppImageManagerTest.kt @@ -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")) + } +} diff --git a/tools/linuxfs/desktop/droiddeck-desktop-gpu b/tools/linuxfs/desktop/droiddeck-desktop-gpu index 4a53b3d4e..8a9a6b108 100644 --- a/tools/linuxfs/desktop/droiddeck-desktop-gpu +++ b/tools/linuxfs/desktop/droiddeck-desktop-gpu @@ -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): diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-appimage-run b/tools/linuxfs/overlay/usr/local/bin/bannerlator-appimage-run new file mode 100755 index 000000000..8d7c85163 --- /dev/null +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-appimage-run @@ -0,0 +1,22 @@ +#!/bin/bash +# An AppImage imported from the front end (AppImageManager), extracted once at import into +# /opt/appimages/user//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/ [args...] +set -u +dir=${1:?usage: bannerlator-appimage-run [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" "$@"