From c63355be6ce2cb3fa35e9372b2d34b2b526cc796 Mon Sep 17 00:00:00 2001 From: xXJsonDeruloXx Date: Tue, 29 Sep 2026 08:06:50 -0400 Subject: [PATCH] feat: run Flatpak apps through a proot bwrap stand-in and add a Flathub store --- .../main/java/com/droiddeck/launcher/App.kt | 7 +- .../com/droiddeck/launcher/MainActivity.kt | 11 +- .../droiddeck/launcher/core/HostProcess.kt | 3 +- .../droiddeck/launcher/frontend/Library.kt | 8 +- .../launcher/runtime/BwrapSpawner.kt | 324 +++++++++++++ .../launcher/runtime/FlatpakManager.kt | 219 +++++++++ .../launcher/runtime/LinuxRuntime.java | 58 ++- .../launcher/session/OrphanReaper.kt | 32 +- .../launcher/session/SessionFiles.kt | 5 + .../droiddeck/launcher/store/FlathubApi.kt | 158 +++++++ .../droiddeck/launcher/store/StoreState.kt | 139 ++++++ .../droiddeck/launcher/ui/FrontEndContent.kt | 5 + .../com/droiddeck/launcher/ui/FrontEndRail.kt | 2 + .../droiddeck/launcher/ui/FrontEndScreen.kt | 2 + .../com/droiddeck/launcher/ui/StorePage.kt | 425 ++++++++++++++++++ .../launcher/ui/WirelessAdbFixPage.kt | 2 +- .../overlay/usr/local/bin/bannerlator-bwrap | 318 +++++++++++++ .../overlay/usr/local/bin/bannerlator-flatpak | 186 ++++++++ .../usr/local/bin/bannerlator-flatpak-run | 23 + .../usr/local/bin/bannerlator-flatpak-setup | 83 ++++ .../overlay/usr/local/bin/bannerlator-session | 4 + .../patches/0010-new-mount-api-enosys.patch | 105 +++++ 22 files changed, 2106 insertions(+), 13 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/runtime/BwrapSpawner.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/runtime/FlatpakManager.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/store/FlathubApi.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/store/StoreState.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/StorePage.kt create mode 100644 tools/linuxfs/overlay/usr/local/bin/bannerlator-bwrap create mode 100644 tools/linuxfs/overlay/usr/local/bin/bannerlator-flatpak create mode 100644 tools/linuxfs/overlay/usr/local/bin/bannerlator-flatpak-run create mode 100644 tools/linuxfs/overlay/usr/local/bin/bannerlator-flatpak-setup create mode 100644 tools/proot/patches/0010-new-mount-api-enosys.patch diff --git a/app/src/main/java/com/droiddeck/launcher/App.kt b/app/src/main/java/com/droiddeck/launcher/App.kt index e1764bc57..8caeae182 100644 --- a/app/src/main/java/com/droiddeck/launcher/App.kt +++ b/app/src/main/java/com/droiddeck/launcher/App.kt @@ -1,12 +1,17 @@ package com.droiddeck.launcher import android.app.Application +import com.droiddeck.launcher.runtime.BwrapSpawner import com.droiddeck.launcher.session.CrashHandler -/** Process-wide setup: the crash handler, so a session's folder is finished even when we die. */ +/** + * Process-wide setup: the crash handler, so a session's folder is finished even when we die, and + * the socket Flatpak's sandboxes are started through (BwrapSpawner). + */ class App : Application() { override fun onCreate() { super.onCreate() CrashHandler.install(this) + BwrapSpawner.start(this) } } diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index 4ce8e899a..7e36d9bdb 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -326,6 +326,14 @@ class MainActivity : ComponentActivity() { .putExtra(SessionService.EXTRA_MODE, SessionService.MODE_DESKTOP)) }, onEmulator = { e -> launchProgram(e.program) }, + // A Flatpak app from the store, full screen under gamescope like an emulator. + onFlatpakApp = { id, name -> + Library.flatpakNames[id] = name + startActivity(Intent(this, SessionActivity::class.java) + .putExtra(SessionService.EXTRA_MODE, SessionService.MODE_RUN) + .putExtra(SessionService.EXTRA_PROGRAM, com.droiddeck.launcher.runtime.FlatpakManager.LAUNCHER) + .putExtra(SessionService.EXTRA_PROGRAM_ARGS, arrayOf(id))) + }, onRom = { g -> val e = emulatorList.first { it.id == g.emulatorId } startActivity(Intent(this, SessionActivity::class.java) @@ -1005,7 +1013,8 @@ class MainActivity : ComponentActivity() { logsEnabled = SessionPrefs.logsEnabled(this) runningLabel = if (SessionState.running) when (SessionState.mode) { SessionService.MODE_DESKTOP -> "Desktop" - SessionService.MODE_RUN -> SessionState.program?.substringAfterLast('/')?.substringBefore('.') ?: "Program" + SessionService.MODE_RUN -> Library.nameForProgram(SessionState.program) + ?: SessionState.program?.substringAfterLast('/')?.substringBefore('.') ?: "Program" else -> "Steam" } else null // The libraries, off the main thread: manifests and a folder scan. diff --git a/app/src/main/java/com/droiddeck/launcher/core/HostProcess.kt b/app/src/main/java/com/droiddeck/launcher/core/HostProcess.kt index d73352af7..79334af3b 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/HostProcess.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/HostProcess.kt @@ -64,7 +64,8 @@ object HostProcess { } } - private fun pidOf(process: Process): Int = try { + @JvmStatic + fun pidOf(process: Process): Int = try { val field = process.javaClass.getDeclaredField("pid") field.isAccessible = true val pid = field.getInt(process) 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 29191c37e..a43260a76 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt @@ -163,7 +163,13 @@ object Library { ) /** The emulator's name for a program path from the rail ("ARMSX2"), or null. */ - fun nameForProgram(program: String?): String? = specs.firstOrNull { it.program == program }?.name + fun nameForProgram(program: String?): String? = + if (program == com.droiddeck.launcher.runtime.FlatpakManager.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. */ + val flatpakNames = java.util.concurrent.ConcurrentHashMap() /** * Whether a program from the rail runs at the panel's own resolution rather than the session's diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/BwrapSpawner.kt b/app/src/main/java/com/droiddeck/launcher/runtime/BwrapSpawner.kt new file mode 100644 index 000000000..b6579bc2a --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/runtime/BwrapSpawner.kt @@ -0,0 +1,324 @@ +package com.droiddeck.launcher.runtime + +import android.content.Context +import android.net.LocalServerSocket +import android.net.LocalSocket +import android.os.Process +import android.system.Os +import android.util.Base64 +import android.util.Log +import com.droiddeck.launcher.core.FileUtils +import com.droiddeck.launcher.core.HostProcess +import com.droiddeck.launcher.session.SessionPrefs +import org.json.JSONObject +import java.io.DataInputStream +import java.io.DataOutputStream +import java.io.File +import java.io.IOException +import java.nio.file.Files +import java.util.concurrent.atomic.AtomicInteger + +/** + * Flatpak's sandboxes, started for the runtime's bwrap stand-in (bannerlator-bwrap). + * + * A Flatpak app runs with its runtime as /usr and itself as /app, which bwrap arranges with mount + * namespaces Android gives no app. proot can show the same picture, and started here - beside the + * session's proot rather than inside it - it traces the app once instead of twice. The stand-in + * sends the sandbox Flatpak asked for (its binds, directories, symlinks and files, in guest paths), + * this builds it under the cache and starts it, then streams the output, the pid and the exit + * status back until one side goes away: the stand-in ending takes the sandbox down with it. + * + * The socket is abstract, so anything on the device could connect: only this app's own uid - the + * uid every program in the runtime already runs as - is served. + */ +object BwrapSpawner { + private const val TAG = "BwrapSpawner" + const val SOCKET = "com.droiddeck.launcher.bwrap" + private val seq = AtomicInteger() + @Volatile private var server: LocalServerSocket? = null + + /** Listens for sandboxes for the rest of the process's life; later calls do nothing. */ + @Synchronized + fun start(context: Context) { + if (server != null) return + val app = context.applicationContext + val socket = try { + LocalServerSocket(SOCKET) + } catch (e: IOException) { + Log.e(TAG, "cannot listen on $SOCKET", e) + return + } + server = socket + FileUtils.delete(File(app.cacheDir, "bwrap")) + Thread({ + while (true) { + val client = try { socket.accept() } catch (e: IOException) { Log.e(TAG, "accept", e); break } + Thread({ serve(app, client) }, "bwrap-${seq.get() + 1}").apply { isDaemon = true; start() } + } + }, "bwrap-server").apply { isDaemon = true; start() } + } + + private fun serve(context: Context, client: LocalSocket) { + val id = seq.incrementAndGet() + val dir = File(context.cacheDir, "bwrap/$id") + client.use { + val out = DataOutputStream(client.outputStream) + try { + val peer = client.peerCredentials + if (peer.uid != Process.myUid()) { + Log.w(TAG, "refused a sandbox for uid ${peer.uid}") + return + } + val input = DataInputStream(client.inputStream) + val kind = input.readUnsignedByte() + val body = ByteArray(input.readInt()).also { input.readFully(it) } + if (kind != 'R'.code) return + val request = JSONObject(String(body, Charsets.UTF_8)) + val plan = plan(context, request, dir) + run(context, plan, client, out, id) + } catch (e: Exception) { + Log.e(TAG, "sandbox $id", e) + runCatching { + frame(out, 'E', (e.message ?: e.toString()).toByteArray()) + frame(out, 'X', "1".toByteArray()) + } + } finally { + FileUtils.delete(dir) + } + } + } + + /** What proot is started with: the sandbox root, its binds (host to guest), and the program. */ + class Plan(val root: File, val binds: List, val cwd: String, val argv: List, val env: Map) + + data class Bind(val host: String, val guest: String) { + val spec: String get() = if (host == guest) host else "$host:$guest" + + companion object { + fun parse(spec: String): Bind { + val colon = spec.indexOf(':') + return if (colon < 0) Bind(spec, spec) else Bind(spec.substring(0, colon), spec.substring(colon + 1)) + } + } + } + + /** True when [path] is [dir] or inside it. */ + internal fun under(path: String, dir: String): Boolean = + dir == "/" || path == dir || path.startsWith(if (dir.endsWith("/")) dir else "$dir/") + + /** + * Where a path the session's programs see lives on the host: through the session's own binds + * (the most specific one), or else inside the rootfs. + */ + internal fun hostPath(sessionBinds: List, rootfs: String, guest: String): String { + val bind = sessionBinds.filter { under(guest, it.guest) }.maxByOrNull { it.guest.length } + ?: return rootfs.trimEnd('/') + guest + val rest = guest.substring(bind.guest.length).trimStart('/') + return if (rest.isEmpty()) bind.host else bind.host.trimEnd('/') + "/" + rest + } + + /** + * Guest [src] bound at [dest]: the bind itself, then every session bind below [src] moved + * under [dest] - /dev brings the session's /dev/shm and GPU node, /proc its stand-ins for what + * Android keeps apps from reading. + */ + internal fun bindsFor(sessionBinds: List, rootfs: String, src: String, dest: String): List { + val out = ArrayList() + out.add(Bind(hostPath(sessionBinds, rootfs, src), dest)) + for (b in sessionBinds.sortedBy { it.guest.length }) { + if (b.guest != src && under(b.guest, src)) { + out.add(Bind(b.host, (dest.trimEnd('/') + "/" + b.guest.substring(src.length).trimStart('/')))) + } + } + return out + } + + internal fun plan(context: Context, request: JSONObject, dir: File): Plan { + val rootfs = LinuxRuntime.rootDir(context).path + val session = LinuxRuntime.lastBinds(context).map { Bind.parse(it) } + val root = File(dir, "root").apply { mkdirs() } + val binds = ArrayList() + var scratch = 0 + fun addBind(b: Bind) { + binds.removeAll { it.guest == b.guest } + binds.add(b) + } + fun coveringBind(dest: String) = binds.filter { under(dest, it.guest) }.maxByOrNull { it.guest.length } + fun newScratch(): File = File(dir, "x${scratch++}") + /** + * Where [dest] can be created on the host: inside the sandbox root, or inside a directory + * of ours bound into it (a --tmpfs under the runtime's /usr, which Flatpak fills with the + * GL extension's links). Null inside anything else bound in - the runtime itself, a host + * directory - which must not be written to. + */ + fun writable(dest: String): File? { + val cover = coveringBind(dest) ?: return inRoot(root, dest) + if (!under(cover.host, dir.path)) return null + return inRoot(File(cover.host), dest.substring(cover.guest.length)) + } + val ops = request.getJSONArray("ops") + for (i in 0 until ops.length()) { + val op = ops.getJSONObject(i) + val dest = op.optString("dest") + when (op.getString("t")) { + "bind" -> { + val src = op.getString("src") + val all = bindsFor(session, rootfs, src, dest) + if (!File(all.first().host).exists()) { + if (!op.optBoolean("try")) Log.w(TAG, "sandbox source $src is missing; left out") + continue + } + all.forEach { addBind(it) } + } + "proc" -> bindsFor(session, rootfs, "/proc", dest).forEach { addBind(it) } + "dev" -> bindsFor(session, rootfs, "/dev", dest).forEach { addBind(it) } + "dir" -> { + val perms = op.optInt("perms", 0x1ed) + val here = writable(dest) + when { + op.optBoolean("empty") && coveringBind(dest) != null -> + addBind(Bind(newScratch().apply { mkdirs(); chmod(this, perms) }.path, dest)) + here != null && (here.isDirectory || here.mkdirs()) -> chmod(here, perms) + File(hostPath(binds, root.path, dest)).isDirectory -> {} + else -> addBind(Bind(newScratch().apply { mkdirs(); chmod(this, perms) }.path, dest)) + } + } + "file" -> { + val data = Base64.decode(op.getString("data"), Base64.DEFAULT) + val here = writable(dest) + val f = if (here != null && here.parentFile?.let { it.isDirectory || it.mkdirs() } == true) here + else newScratch().also { addBind(Bind(it.path, dest)) } + f.writeBytes(data) + chmod(f, op.optInt("perms", 0x1b6)) + } + "symlink" -> { + val target = op.getString("target") + val link = writable(dest) + if (link != null) { + link.parentFile?.mkdirs() + if (!Files.isSymbolicLink(link.toPath()) && !link.exists()) Os.symlink(target, link.path) + } else { + // A link inside the runtime's own tree cannot be made; what it points at is + // bound in its place. + val abs = if (target.startsWith("/")) target else normalize(dest.substringBeforeLast('/') + "/" + target) + val host = hostPath(binds, root.path, abs) + if (File(host).exists()) addBind(Bind(host, dest)) else Log.w(TAG, "symlink $dest -> $target: nothing there; left out") + } + } + } + } + if (binds.any { it.guest == "/proc" }) genericCpuinfo(context)?.let { addBind(Bind(it.path, "/proc/cpuinfo")) } + val argv = request.getJSONArray("argv").let { a -> (0 until a.length()).map { a.getString(it) } } + val env = LinkedHashMap() + request.optJSONObject("env")?.let { e -> e.keys().forEach { k -> env[k] = e.getString(k) } } + return Plan(root, binds, request.optString("cwd", "/").ifEmpty { "/" }, argv, env) + } + + /** + * /proc/cpuinfo without the cores' names. Snapdragon's ARMv9 cores (Cortex-A510/A715/X3) have + * SVE2 by architecture, and LLVM - llvmpipe's JIT in Flathub's Mesa, which is what apps draw + * with here - takes the CPU part to mean it may use it. Qualcomm leaves SVE off, so the first + * shader compiled killed the app with SIGILL. Without a part number LLVM targets generic + * ARMv8 plus the "Features" line, which lists what the kernel really enabled. + */ + private fun genericCpuinfo(context: Context): File? = try { + val out = File(context.cacheDir, "cpuinfo-generic") + val text = File("/proc/cpuinfo").readLines() + .filterNot { it.startsWith("CPU implementer") || it.startsWith("CPU part") || it.startsWith("CPU variant") || it.startsWith("CPU revision") } + .joinToString("\n", postfix = "\n") + if (!out.isFile || out.readText() != text) out.writeText(text) + out + } catch (e: Exception) { + Log.w(TAG, "cpuinfo", e); null + } + + /** [path] with "." and ".." resolved lexically. */ + internal fun normalize(path: String): String { + val out = ArrayList() + for (part in path.split('/')) when (part) { + "", "." -> {} + ".." -> if (out.isNotEmpty()) out.removeAt(out.size - 1) + else -> out.add(part) + } + return "/" + out.joinToString("/") + } + + /** + * [guest] inside the sandbox root, or null when the way there crosses a symlink: one of + * Flatpak's (var/run -> ../run) may point anywhere once followed on the host. + */ + private fun inRoot(root: File, guest: String): File? { + var f = root + for (part in guest.split('/').filter { it.isNotEmpty() }) { + if (part == "..") return null + f = File(f, part) + if (Files.isSymbolicLink(f.toPath())) return null + } + return f + } + + private fun chmod(f: File, mode: Int) { + try { Os.chmod(f.path, mode) } catch (e: Exception) { /* the default mode serves */ } + } + + private fun run(context: Context, plan: Plan, client: LocalSocket, out: DataOutputStream, id: Int) { + val cmd = LinuxRuntime.prootPrefix(context, plan.root, plan.cwd) + // DROIDDECK_PROOT_VERBOSE=N in the app's environment (flatpak run --env=...) has proot + // say what it does, to logcat in full: for a sandbox that dies without a word. + val verbose = plan.env["DROIDDECK_PROOT_VERBOSE"]?.toIntOrNull() + if (verbose != null) { cmd.add(1, "-v"); cmd.add(2, verbose.toString()) } + plan.binds.forEach { cmd.add("-b"); cmd.add(it.spec) } + // proot hands its own environment on; the sandbox gets exactly what Flatpak asked for. + cmd.add("/usr/bin/env") + cmd.add("-i") + plan.env.forEach { (k, v) -> cmd.add("$k=$v") } + cmd.addAll(plan.argv) + val builder = ProcessBuilder(cmd).redirectErrorStream(true).directory(plan.root) + builder.environment().apply { + clear() + 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) + 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. + Thread({ + try { while (client.inputStream.read() >= 0) { /* nothing is sent after the request */ } } + catch (e: IOException) { /* closed */ } + if (process.isAlive) { + Log.i(TAG, "sandbox $id: its bwrap is gone; stopping it") + process.destroy() + } + }, "bwrap-$id-watch").apply { isDaemon = true; start() } + val buffer = ByteArray(16384) + // The start of what the sandbox says goes to logcat too: a sandbox that fails at once + // (proot refusing a bind, a missing library) says why there even when nothing reads it. + var logged = 0 + process.inputStream.use { stream -> + while (true) { + val n = try { stream.read(buffer) } catch (e: IOException) { -1 } + if (n < 0) break + if (logged < 4096 || verbose != null) { + String(buffer, 0, if (verbose != null) n else minOf(n, 4096 - logged)).lines().filter { it.isNotBlank() } + .forEach { Log.i(TAG, "sandbox $id: $it") } + logged += n + } + try { synchronized(out) { frame(out, 'O', buffer.copyOf(n)) } } catch (e: IOException) { break } + } + } + val status = process.waitFor() + Log.i(TAG, "sandbox $id exited $status") + runCatching { synchronized(out) { frame(out, 'X', status.toString().toByteArray()) } } + } + + private fun frame(out: DataOutputStream, kind: Char, payload: ByteArray) { + out.writeByte(kind.code) + out.writeInt(payload.size) + out.write(payload) + out.flush() + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/FlatpakManager.kt b/app/src/main/java/com/droiddeck/launcher/runtime/FlatpakManager.kt new file mode 100644 index 000000000..5813b3793 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/runtime/FlatpakManager.kt @@ -0,0 +1,219 @@ +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 + +/** + * Flatpak in the Linux runtime: putting it there (bannerlator-flatpak-setup), the apps installed + * from Flathub, and installing, removing and updating them (bannerlator-flatpak). One per-user + * installation at /root/.local/share/flatpak, so nothing needs the system helper or polkit. + * + * Every command runs in a proot of its own, outside any session: the store works whether or not + * Steam or the desktop is up, and a session starting meanwhile leaves it running (OrphanReaper). + */ +object FlatpakManager { + private const val TAG = "FlatpakManager" + const val FLATPAK = "/usr/bin/flatpak" + const val BWRAP = "/usr/local/bin/bannerlator-bwrap" + /** The program the front end starts a Flatpak app through, in a session of its own. */ + const val LAUNCHER = "/usr/local/bin/bannerlator-flatpak-run" + private const val HELPER = "/usr/local/bin/bannerlator-flatpak" + private const val SETUP = "/usr/local/bin/bannerlator-flatpak-setup" + private const val USER_DIR = "root/.local/share/flatpak" + + /** One store operation at a time: Flatpak locks its installation per transaction anyway. */ + @Volatile var busy: String? = null + private set + + class App(val id: String, val name: String, val icon: File?, val branch: String, val summary: String?) + + /** Flatpak is in the runtime, with Flathub to install from. */ + fun ready(context: Context): Boolean { + val root = LinuxRuntime.rootDir(context) + return File(root, FLATPAK.substring(1)).isFile && + File(root, "var/cache/droiddeck-flatpak/installed").isFile && + (FileUtils.readString(File(root, "$USER_DIR/repo/config"))?.contains("[remote \"flathub\"]") == true) + } + + private fun arch(): String = when (android.os.Build.SUPPORTED_ABIS.firstOrNull()) { + "x86_64" -> "x86_64" + else -> "aarch64" + } + + /** + * The installed apps, read from the installation itself: each app's deployed files carry the + * desktop entry and icons it exports, so listing them needs no process in the runtime. + */ + fun installedApps(context: Context): List { + val apps = File(LinuxRuntime.rootDir(context), "$USER_DIR/app") + val arch = arch() + return apps.listFiles()?.mapNotNull { dir -> + val id = dir.name + val archDir = File(dir, arch) + val branch = archDir.listFiles()?.firstOrNull { File(it, "active").exists() } ?: return@mapNotNull null + val active = File(branch, "active") + val export = File(active, "export/share") + val desktop = FileUtils.readString(File(export, "applications/$id.desktop")) + val name = desktop?.let { entry(it, "Name") } ?: id.substringAfterLast('.') + val summary = desktop?.let { entry(it, "Comment") } + App(id, name, icon(export, id), branch.name, summary) + }?.sortedBy { it.name.lowercase() } ?: emptyList() + } + + fun isInstalled(context: Context, id: String): Boolean = + File(LinuxRuntime.rootDir(context), "$USER_DIR/app/$id/${arch()}").listFiles()?.any { File(it, "active").exists() } == true + + /** A key of the desktop entry's main group ([Desktop Entry]), unlocalised. */ + private fun entry(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 + } + + /** The largest PNG the app exports (the front end draws no SVG). */ + private fun icon(export: File, id: String): File? { + val hicolor = File(export, "icons/hicolor") + return hicolor.listFiles() + ?.mapNotNull { size -> size.name.substringBefore('x').toIntOrNull()?.let { it to File(size, "apps/$id.png") } } + ?.filter { it.second.isFile } + ?.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. + */ + private fun runGuest(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) + } + } + + private inline fun exclusive(what: String, block: () -> T): T? { + synchronized(this) { + if (busy != null) return null + busy = what + } + try { return block() } finally { busy = null } + } + + /** Puts Flatpak into the runtime and adds Flathub. Null on success, else what went wrong. */ + fun setup(context: Context, onProgress: (String, Int) -> Unit): String? { + if (!LinuxRuntime.isInstalled(context)) return "Install the Linux runtime first" + return exclusive("setup") { + var failure: String? = null + val status = runGuest(context, listOf("/bin/bash", SETUP), fakeRoot = true) { line -> + Log.i(TAG, "setup: $line") + when { + line.startsWith("== STEP ") -> onProgress(line.removePrefix("== STEP "), -1) + line.startsWith("== FAIL ") -> failure = line.removePrefix("== FAIL ") + } + } + when { + failure != null -> failure + status != 0 -> "Flatpak setup failed (exit $status)" + !ready(context) -> "Flatpak setup did not finish" + else -> null + } + } ?: "Another store task is running" + } + + /** + * One store operation - install, uninstall or update - with its progress as a stage line and + * an overall percentage across the steps Flatpak plans (a runtime, its extensions, the app). + */ + private fun transaction(context: Context, verb: String, id: String?, onProgress: (String, Int) -> Unit): String? { + if (!ready(context)) return "Set up Flatpak first" + return exclusive("$verb:${id ?: "all"}") { + var error: String? = null + val argv = listOfNotNull("/usr/bin/python3", HELPER, verb, id) + val status = runGuest(context, argv, fakeRoot = false) { line -> + val o = runCatching { JSONObject(line) }.getOrNull() + if (o == null) { Log.i(TAG, "$verb: $line"); return@runGuest } + when (o.optString("e")) { + "op" -> onProgress(stage(o), overall(o, 0)) + "progress" -> onProgress(stage(o), overall(o, o.optInt("percent"))) + "error" -> { error = o.optString("message"); Log.w(TAG, "$verb $id: $error") } + "warning" -> Log.w(TAG, "$verb $id: ${o.optString("message")}") + } + } + error ?: if (status != 0) "Flatpak exited with status $status" else null + } ?: "Another store task is running" + } + + private fun stage(o: JSONObject): String { + val ref = o.optString("ref") + val name = ref.split('/').getOrNull(1)?.substringAfterLast('.') ?: "" + val step = if (o.optInt("n") > 1) " (${o.optInt("i")}/${o.optInt("n")})" else "" + return when (o.optString("kind")) { + "uninstall" -> "Removing $name$step" + "update" -> "Updating $name$step" + else -> "Installing $name$step" + }.let { if (ref.isEmpty()) o.optString("status").ifEmpty { "Working" } else it } + } + + /** The whole transaction's percentage: steps done, plus this one's share. */ + private fun overall(o: JSONObject, percent: Int): Int { + val n = o.optInt("n").coerceAtLeast(1) + val i = o.optInt("i").coerceIn(1, n) + return (((i - 1) * 100 + percent.coerceIn(0, 100)) / n) + } + + fun install(context: Context, id: String, onProgress: (String, Int) -> Unit) = transaction(context, "install", id, onProgress) + fun uninstall(context: Context, id: String, onProgress: (String, Int) -> Unit) = transaction(context, "uninstall", id, onProgress) + fun update(context: Context, id: String?, onProgress: (String, Int) -> Unit) = transaction(context, "update", id, onProgress) + + /** Installed apps with an update on Flathub, or null when that could not be checked. */ + fun updates(context: Context): Set? { + if (!ready(context)) return emptySet() + var apps: Set? = null + exclusive("updates") { + runGuest(context, listOf("/usr/bin/python3", HELPER, "updates"), fakeRoot = false) { line -> + val o = runCatching { JSONObject(line) }.getOrNull() ?: return@runGuest + if (o.optString("e") == "updates") { + val a = o.optJSONArray("apps") + apps = (0 until (a?.length() ?: 0)).map { a!!.getString(it) }.toSet() + } + } + } + return apps + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java index c8875dade..9440c0ec3 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java +++ b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java @@ -115,6 +115,37 @@ public final class LinuxRuntime { File externalStorage, List extraBinds, List guestCommand) { File root = rootDir(context); + List binds = binds(context, sessionRoot, runtimeDir, externalStorage, extraBinds); + // A session's binds are the view its programs have; a one-off command's are not recorded. + if (sessionRoot != null) lastBinds = binds; + List cmd = prootPrefix(context, root, "/root"); + for (String spec : binds) bind(cmd, spec); + cmd.addAll(guestCommand); + return cmd; + } + + /** + * The binds of the last command built: the guest's view of the host, which the Flatpak + * sandboxes started beside a session (BwrapSpawner) translate their paths through. + */ + private static volatile List lastBinds; + + public static List lastBinds(Context context) { + List binds = lastBinds; + if (binds != null) return binds; + return binds(context, null, null, android.os.Environment.getExternalStorageDirectory(), null); + } + + /** proot and its options up to the binds: the rootfs at {@code root}, starting in {@code cwd}. */ + public static List prootPrefix(Context context, File root, String cwd) { + return prootPrefix(context, root, cwd, false); + } + + /** + * As above; {@code fakeRoot} has the guest see uid 0 (proot -0), for the package tools that + * refuse any other uid. Files it writes still belong to the app. + */ + public static List prootPrefix(Context context, File root, String cwd, boolean fakeRoot) { List cmd = new ArrayList<>(); cmd.add(prootBinary(context).getPath()); cmd.add("--kill-on-exit"); @@ -125,12 +156,24 @@ public final class LinuxRuntime { // this the keymap never compiles and Xwayland dies. -i makes proot answer those calls // itself while still reporting our real ids, so nothing inside sees a different user. int uid = Process.myUid(); - cmd.add("-i"); - cmd.add(uid + ":" + uid); + if (fakeRoot) { + cmd.add("-0"); + } else { + cmd.add("-i"); + cmd.add(uid + ":" + uid); + } cmd.add("-r"); cmd.add(root.getPath()); cmd.add("-w"); - cmd.add("/root"); + cmd.add(cwd); + return cmd; + } + + /** The {@code host:guest} (or same-path) bind specs of a session; see {@link #command}. */ + public static List binds(Context context, File sessionRoot, File runtimeDir, + File externalStorage, List extraBinds) { + File root = rootDir(context); + List cmd = new ArrayList<>(); bind(cmd, "/dev"); bind(cmd, "/proc"); bind(cmd, "/sys"); @@ -142,7 +185,7 @@ public final class LinuxRuntime { bind(cmd, new File(root, "etc/bannerlator/empty").getPath() + ":/sys/fs/selinux"); bind(cmd, context.getFilesDir().getPath()); bind(cmd, context.getCacheDir().getPath()); - bind(cmd, runtimeDir.getPath()); + if (runtimeDir != null) bind(cmd, runtimeDir.getPath()); if (sessionRoot != null) bind(cmd, sessionRoot.getPath()); if (externalStorage != null && externalStorage.isDirectory()) { bind(cmd, externalStorage.getPath()); @@ -196,8 +239,9 @@ public final class LinuxRuntime { if (extraBinds != null) { for (String spec : extraBinds) bind(cmd, spec); } - cmd.addAll(guestCommand); - return cmd; + List specs = new ArrayList<>(); + for (int i = 0; i + 1 < cmd.size(); i += 2) specs.add(cmd.get(i + 1)); + return specs; } /** @@ -250,7 +294,7 @@ public final class LinuxRuntime { } /** PRoot's complex -k format: sysname, nodename, release, version, machine, domain, HWCAP. */ - private static String guestUtsname(String hostname) { + static String guestUtsname(String hostname) { StructUtsname host = Os.uname(); return "\\" + host.sysname + "\\" + hostname + "\\" + host.release + "\\" + host.version + "\\" + host.machine + "\\localdomain\\-1\\"; diff --git a/app/src/main/java/com/droiddeck/launcher/session/OrphanReaper.kt b/app/src/main/java/com/droiddeck/launcher/session/OrphanReaper.kt index 9264a7f6a..9a17a97ae 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/OrphanReaper.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/OrphanReaper.kt @@ -13,17 +13,28 @@ import java.io.File * force-stop the app before every launch. Everything of ours that should be alive between * sessions is inside the app process, so at session start anything else under our uid is a * leftover, and this sweeps it. Each pid is checked by uid at kill time, never by name. + * + * The one exception is work the app started outside any session and is still waiting on - a + * Flatpak install from the store - registered with [keep]: that process and everything under it + * are left to finish. */ object OrphanReaper { private const val TAG = "OrphanReaper" + private val kept = java.util.concurrent.ConcurrentHashMap.newKeySet() + + /** Spares [pid] and its descendants until [release]. */ + fun keep(pid: Int) { if (pid > 0) kept.add(pid) } + + fun release(pid: Int) { kept.remove(pid) } fun reap(reason: String): Int { val me = android.os.Process.myPid() val uid = android.os.Process.myUid() + val spared = descendants(kept) var killed = 0 File("/proc").listFiles()?.forEach { entry -> val pid = entry.name.toIntOrNull() ?: return@forEach - if (pid == me || pid <= 1) return@forEach + if (pid == me || pid <= 1 || pid in spared) return@forEach if (uidOf(pid) != uid) return@forEach val name = try { File(entry, "cmdline").readText().substringBefore('\u0000') } catch (e: Exception) { "?" } try { @@ -38,6 +49,25 @@ object OrphanReaper { return killed } + /** [roots] and every process below them, by each process's parent in /proc//stat. */ + private fun descendants(roots: Set): Set { + if (roots.isEmpty()) return emptySet() + val parent = HashMap() + File("/proc").listFiles()?.forEach { entry -> + val pid = entry.name.toIntOrNull() ?: return@forEach + val stat = try { File(entry, "stat").readText() } catch (e: Exception) { return@forEach } + // "pid (comm) state ppid ...": comm may hold spaces and parentheses; the last ')' ends it. + stat.substringAfterLast(") ").split(' ').getOrNull(1)?.toIntOrNull()?.let { parent[pid] = it } + } + val out = HashSet(roots) + var grew = true + while (grew) { + grew = false + for ((pid, ppid) in parent) if (ppid in out && out.add(pid)) grew = true + } + return out + } + private fun uidOf(pid: Int): Int { return try { File("/proc/$pid/status").useLines { lines -> 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 2bb2f1010..6dc663f7d 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt @@ -42,6 +42,11 @@ object SessionFiles { "usr/local/bin/bannerlator-steam-shortcuts" to "usr/local/bin/bannerlator-steam-shortcuts", "usr/local/bin/bannerlator-steam-games" to "usr/local/bin/bannerlator-steam-games", "usr/local/bin/bannerlator-pad-defaults" to "usr/local/bin/bannerlator-pad-defaults", + // Flatpak: the bwrap stand-in, the store's helper and setup, and the front end's launcher. + "usr/local/bin/bannerlator-bwrap" to "usr/local/bin/bannerlator-bwrap", + "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 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/FlathubApi.kt b/app/src/main/java/com/droiddeck/launcher/store/FlathubApi.kt new file mode 100644 index 000000000..536e2aa9e --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/store/FlathubApi.kt @@ -0,0 +1,158 @@ +package com.droiddeck.launcher.store + +import android.util.Log +import org.json.JSONArray +import org.json.JSONObject +import java.net.HttpURLConnection +import java.net.URL +import java.net.URLEncoder + +/** + * Flathub's public API (flathub.org/api/v2): the collections its front page shows, search, and an + * app's AppStream data. Only what this device can install is kept - apps built for aarch64. + */ +object FlathubApi { + private const val TAG = "FlathubApi" + private const val BASE = "https://flathub.org/api/v2" + private const val ARCH = "aarch64" + private const val TIMEOUT_MS = 20_000 + + class AppSummary( + val id: String, val name: String, val summary: String, val icon: String?, + val developer: String?, val verified: Boolean, val installs: Int, + ) + + class Screenshot(val thumb: String, val full: String) + + class AppDetails( + val id: String, val name: String, val summary: String, val description: String, + val developer: String?, val icon: String?, val version: String?, val license: String?, + val homepage: String?, val screenshots: List, val categories: List, + val runtime: String?, val downloadSize: Long, val installedSize: Long, val arches: List, + ) + + /** A category as Flathub's search filters it, with the name shown for it. */ + class Category(val id: String, val label: String) + + val categories = listOf( + Category("Game", "Games"), Category("AudioVideo", "Audio & Video"), Category("Graphics", "Graphics"), + Category("Network", "Internet"), Category("Office", "Office"), Category("Development", "Developer"), + Category("Education", "Education"), Category("Science", "Science"), Category("System", "System"), + Category("Utility", "Utilities"), + ) + + /** Front-page collections: popular, trending, recently-updated, recently-added. */ + fun collection(name: String, count: Int = 30): List? { + // The collections are not filtered by architecture; ask for more and keep what fits. + val body = get("$BASE/collection/$name?page=1&per_page=${count * 2}") ?: return null + return hits(body)?.take(count) + } + + /** Search, optionally within one category (Flathub's main_categories facet). */ + fun search(query: String, category: String? = null, count: Int = 48): List? { + val filters = JSONArray().put(JSONObject().put("filterType", "arches").put("value", ARCH)) + if (category != null) filters.put(JSONObject().put("filterType", "main_categories").put("value", category)) + val payload = JSONObject().put("query", query).put("filters", filters) + .put("hits_per_page", count).put("page", 1) + val body = post("$BASE/search", payload.toString()) ?: return null + return hits(body) + } + + /** Flathub's own copy of an app's icon: for apps that export only an SVG, which is not drawn here. */ + fun iconUrl(id: String) = "https://dl.flathub.org/media/icons/128x128/$id.png" + + fun details(id: String): AppDetails? { + val app = get("$BASE/appstream/${enc(id)}")?.let { runCatching { JSONObject(it) }.getOrNull() } ?: return null + val summary = get("$BASE/summary/${enc(id)}?arch=$ARCH")?.let { runCatching { JSONObject(it) }.getOrNull() } + val shots = app.optJSONArray("screenshots")?.let { arr -> + (0 until arr.length()).mapNotNull { i -> + val sizes = arr.optJSONObject(i)?.optJSONArray("sizes") ?: return@mapNotNull null + val all = (0 until sizes.length()).mapNotNull { sizes.optJSONObject(it) } + .map { (it.optString("width").toIntOrNull() ?: 0) to it.optString("src") } + .filter { it.second.startsWith("http") }.sortedBy { it.first } + if (all.isEmpty()) null + else Screenshot(all.firstOrNull { it.first >= 600 }?.second ?: all.last().second, + all.firstOrNull { it.first >= 1200 }?.second ?: all.last().second) + } + } ?: emptyList() + val release = app.optJSONArray("releases")?.optJSONObject(0) + val cats = app.optJSONArray("categories")?.let { a -> (0 until a.length()).map { a.optString(it) } } ?: emptyList() + val arches = summary?.optJSONArray("arches")?.let { a -> (0 until a.length()).map { a.optString(it) } } ?: emptyList() + return AppDetails( + id = app.optString("id", id), + name = app.optString("name", id), + summary = app.optString("summary"), + description = plainText(app.optString("description")), + developer = app.optString("developer_name").takeIf { it.isNotEmpty() && it != "null" }, + icon = app.optString("icon").takeIf { it.startsWith("http") }, + version = release?.optString("version")?.takeIf { it.isNotEmpty() && it != "null" }, + license = app.optString("project_license").takeIf { it.isNotEmpty() && it != "null" }, + homepage = app.optJSONObject("urls")?.optString("homepage")?.takeIf { it.startsWith("http") }, + screenshots = shots, + categories = cats, + runtime = summary?.optJSONObject("metadata")?.optString("runtime")?.takeIf { it.isNotEmpty() }, + downloadSize = summary?.optLong("download_size") ?: 0L, + installedSize = summary?.optLong("installed_size") ?: 0L, + arches = arches, + ) + } + + private fun hits(body: String): List? = try { + val arr = JSONObject(body).getJSONArray("hits") + (0 until arr.length()).mapNotNull { i -> + val o = arr.getJSONObject(i) + val arches = o.optJSONArray("arches") + if (arches != null && (0 until arches.length()).none { arches.optString(it) == ARCH }) return@mapNotNull null + if (o.optString("type", "desktop-application") != "desktop-application") return@mapNotNull null + AppSummary( + id = o.optString("app_id"), + name = o.optString("name"), + summary = o.optString("summary"), + icon = o.optString("icon").takeIf { it.startsWith("http") }, + developer = o.optString("developer_name").takeIf { it.isNotEmpty() && it != "null" }, + verified = o.optBoolean("verification_verified"), + installs = o.optInt("installs_last_month"), + ) + }.filter { it.id.isNotEmpty() } + } catch (e: Exception) { + Log.w(TAG, "hits: $e"); null + } + + /** AppStream descriptions are a small HTML subset: paragraphs and lists become lines. */ + internal fun plainText(html: String): String = html + .replace(Regex("(?i)
  • \\s*"), "• ") + .replace(Regex("(?i)"), "\n") + .replace(Regex("<[^>]+>"), "") + .replace("&", "&").replace("<", "<").replace(">", ">").replace(""", "\"").replace("'", "'") + .lines().map { it.trim().replace(Regex("\\s+"), " ") } + .joinToString("\n").replace(Regex("\n{3,}"), "\n\n").trim() + + private fun enc(s: String) = URLEncoder.encode(s, "UTF-8") + + private fun get(url: String): String? = request(url, null) + + private fun post(url: String, json: String): String? = request(url, json) + + private fun request(url: String, json: String?): String? { + var c: HttpURLConnection? = null + return try { + c = (URL(url).openConnection() as HttpURLConnection).apply { + connectTimeout = TIMEOUT_MS + readTimeout = TIMEOUT_MS + setRequestProperty("Accept", "application/json") + if (json != null) { + requestMethod = "POST" + doOutput = true + setRequestProperty("Content-Type", "application/json") + outputStream.use { it.write(json.toByteArray()) } + } + } + if (c.responseCode / 100 != 2) { Log.w(TAG, "$url -> HTTP ${c.responseCode}"); null } + else c.inputStream.bufferedReader().use { it.readText() } + } catch (e: Exception) { + Log.w(TAG, "$url: $e"); null + } finally { + c?.disconnect() + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/store/StoreState.kt b/app/src/main/java/com/droiddeck/launcher/store/StoreState.kt new file mode 100644 index 000000000..e844a9e65 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/store/StoreState.kt @@ -0,0 +1,139 @@ +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.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.droiddeck.launcher.runtime.FlatpakManager + +/** + * The store's state for the life of the process, so an install carries on - and its progress + * shows - while the user moves around the launcher or plays something meanwhile. + */ +object StoreState { + private val main = Handler(Looper.getMainLooper()) + + var ready by mutableStateOf(false) + private set + var installed by mutableStateOf>(emptyList()) + private set + var updates by mutableStateOf>(emptySet()) + private set + var checkingUpdates by mutableStateOf(false) + private set + /** What is running: "setup", an app id, or "update-all"; null when idle. */ + var busy by mutableStateOf(null) + private set + var stage by mutableStateOf(null) + private set + var percent by mutableStateOf(-1) + private set + + /** The front page's sections, by collection name; absent until loaded, empty list on failure. */ + val sections = mutableStateMapOf>() + var sectionsFailed by mutableStateOf(false) + private set + val details = mutableStateMapOf() + val detailsFailed = mutableStateMapOf() + + var searchResults by mutableStateOf?>(null) + private set + var searching by mutableStateOf(false) + private set + private var searchSeq = 0 + + val SECTIONS = listOf("popular" to "Popular", "trending" to "Trending", "recently-added" to "New", "recently-updated" to "Updated") + + fun refresh(context: Context) { + val app = context.applicationContext + Thread({ + val r = FlatpakManager.ready(app) + val list = if (r) FlatpakManager.installedApps(app) else emptyList() + main.post { ready = r; installed = list; updates = updates.filter { id -> list.any { it.id == id } }.toSet() } + }, "store-refresh").start() + } + + fun loadSections(force: Boolean = false) { + if (!force && sections.isNotEmpty()) return + sectionsFailed = false + Thread({ + var failed = false + for ((key, _) in SECTIONS) { + val apps = FlathubApi.collection(key, 18) + if (apps == null) failed = true + main.post { sections[key] = apps ?: emptyList() } + } + main.post { sectionsFailed = failed } + }, "store-sections").start() + } + + fun loadDetails(id: String) { + if (details.containsKey(id)) return + detailsFailed.remove(id) + Thread({ + val d = FlathubApi.details(id) + main.post { if (d != null) details[id] = d else detailsFailed[id] = true } + }, "store-details").start() + } + + fun search(query: String, category: String?) { + val seq = ++searchSeq + searching = true + Thread({ + val r = FlathubApi.search(query, category) + main.post { if (seq == searchSeq) { searchResults = r ?: emptyList(); searching = false } } + }, "store-search").start() + } + + fun clearSearch() { searchSeq++; searchResults = null; searching = false } + + private fun run(context: Context, what: String, label: String, work: (Context, (String, Int) -> Unit) -> String?) { + if (busy != null) return + val app = context.applicationContext + busy = what; stage = "Starting…"; percent = -1 + Thread({ + val problem = try { + work(app) { s, p -> main.post { stage = s; percent = p } } + } catch (e: Exception) { + e.message ?: e.toString() + } + val r = FlatpakManager.ready(app) + val list = if (r) FlatpakManager.installedApps(app) else emptyList() + main.post { + busy = null; stage = null; percent = -1 + ready = r; installed = list + if (problem != null) Toast.makeText(app, "$label: $problem", Toast.LENGTH_LONG).show() + } + }, "store-$what").start() + } + + fun setup(context: Context) = run(context, "setup", "Flatpak setup") { c, p -> FlatpakManager.setup(c, p) } + + fun install(context: Context, id: String, name: String) = + run(context, id, name) { c, p -> FlatpakManager.install(c, id, p) } + + fun uninstall(context: Context, id: String, name: String) = + run(context, id, name) { c, p -> FlatpakManager.uninstall(c, id, p).also { if (it == null) main.post { updates = updates - id } } } + + fun update(context: Context, id: String?, name: String) = + run(context, id ?: "update-all", name) { c, p -> + FlatpakManager.update(c, id, p).also { if (it == null) main.post { updates = if (id == null) emptySet() else updates - id } } + } + + fun checkUpdates(context: Context) { + if (checkingUpdates || busy != null) return + val app = context.applicationContext + checkingUpdates = true + Thread({ + val u = FlatpakManager.updates(app) + main.post { + checkingUpdates = false + if (u != null) updates = u else Toast.makeText(app, "Could not check Flathub for updates", Toast.LENGTH_SHORT).show() + } + }, "store-updates").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 3d6424001..e183763af 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt @@ -154,6 +154,11 @@ private fun Content( SteamHome(s, a, modifier) return } + // The Store scrolls and navigates inside itself (its tabs and an app's page). + if (selected == "store") { + StorePage(s, a, modifier) + return + } // The Games tab lays out its own list and detail. if (selected == "games" || selected.startsWith("app:")) { GamesPage(s, a, selected, onSelect, modifier) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt index 41f38fe41..b4914bd2f 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt @@ -43,6 +43,7 @@ import androidx.compose.material.icons.outlined.Apps import androidx.compose.material.icons.outlined.DesktopWindows import androidx.compose.material.icons.outlined.Layers import androidx.compose.material.icons.outlined.SportsEsports +import androidx.compose.material.icons.outlined.Storefront import androidx.compose.material.icons.outlined.Tune import androidx.compose.material.icons.outlined.VideoLibrary import androidx.compose.ui.graphics.vector.ImageVector @@ -145,6 +146,7 @@ internal fun SideRail( // Always there, so the items below it never move; an empty library says how to fill it. RailItem("Games", Icons.Outlined.VideoLibrary, "games", selected == "games", compact, iconOnly, onFocus = { onFocusSelect("games") }) { onSelect("games") } RailItem("Desktop", Icons.Outlined.DesktopWindows, "desktop", selected == "desktop", compact, iconOnly, onFocus = { onFocusSelect("desktop") }) { onSelect("desktop") } + RailItem("Store", Icons.Outlined.Storefront, "store", selected == "store", compact, iconOnly, onFocus = { onFocusSelect("store") }) { onSelect("store") } RailItem("Components", Icons.Outlined.Layers, "components", selected == "components", compact, iconOnly, onFocus = { onFocusSelect("components") }) { onSelect("components") } // Home mode's extra section goes last, so it shifts nothing above it. if (s.isHomeApp) RailItem("Apps", Icons.Outlined.Apps, "android-apps", selected == "android-apps", compact, iconOnly, onFocus = { onFocusSelect("android-apps") }) { onSelect("android-apps") } 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 d245f0346..3f8d33d33 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt @@ -122,6 +122,8 @@ class FrontEndActions( val onSteamGame: (Library.SteamGame) -> Unit, val onDesktop: () -> Unit, val onEmulator: (Library.Emulator) -> Unit, + /** A Flatpak app by id and name, from the Store. */ + val onFlatpakApp: (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 new file mode 100644 index 000000000..1cd283f5a --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/StorePage.kt @@ -0,0 +1,425 @@ +package com.droiddeck.launcher.ui + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Apps +import androidx.compose.material.icons.outlined.Verified +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil.compose.AsyncImage +import com.droiddeck.launcher.core.FileUtils +import com.droiddeck.launcher.store.FlathubApi +import com.droiddeck.launcher.store.StoreState + +// The Store rail section: Flathub's apps for this device's architecture, installed into the +// Linux runtime with Flatpak, and the ones already installed. + +private val TABS = listOf("Discover", "Search", "Installed") + +@Composable +internal fun StorePage(s: FrontEndState, a: FrontEndActions, modifier: Modifier) { + val ctx = LocalContext.current + val narrow = LocalNarrowPane.current + val padH = if (narrow) 16.dp else 22.dp + val padV = if (narrow) 12.dp else 18.dp + var tab by rememberSaveable { mutableStateOf(0) } + var openApp by rememberSaveable { mutableStateOf(null) } + var query by rememberSaveable { mutableStateOf("") } + var category by rememberSaveable { mutableStateOf(null) } + LaunchedEffect(s.ready) { StoreState.refresh(ctx) } + LaunchedEffect(Unit) { StoreState.loadSections() } + BackHandler(enabled = openApp != null) { openApp = null } + val scroll = rememberScrollState() + LaunchedEffect(openApp, tab) { scroll.scrollTo(0) } + Column( + modifier = modifier.verticalScroll(scroll).padding(horizontal = padH, vertical = padV) + .bumpers(onPrevious = { if (openApp == null) tab = (tab + TABS.size - 1) % TABS.size }, + onNext = { if (openApp == null) tab = (tab + 1) % TABS.size }), + ) { + val id = openApp + if (id != null) { + AppDetail(s, a, id) { openApp = null } + return@Column + } + Rise(0) { + PageHeader("Store") { + Chip("Flathub · ARM64", ok = false) + if (StoreState.ready) Chip("● Flatpak ready", ok = true) + } + } + if (!s.ready) { + Rise(1) { Note("Install the Linux runtime from Setup first; the store installs apps into it.") } + return@Column + } + if (!StoreState.ready) Rise(1) { SetupCard() } + Rise(2) { + TabStrip( + TABS.mapIndexed { i, t -> if (i == 2 && StoreState.installed.isNotEmpty()) "$t · ${StoreState.installed.size}" else t }, + tab, { tab = it }, modifier = Modifier.padding(top = 14.dp, bottom = 4.dp), + ) + } + BusyBar() + when (tab) { + 0 -> Discover(onOpen = { openApp = it }, onCategory = { category = it; query = ""; tab = 1; StoreState.search("", it) }) + 1 -> Search(query, category, { query = it }, { category = it }, onOpen = { openApp = it }) + else -> Installed(a, onOpen = { openApp = it }) + } + } +} + +/** Flatpak is not in the runtime yet: one button puts it there. */ +@Composable +private fun SetupCard() { + val ctx = LocalContext.current + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val busy = StoreState.busy == "setup" + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth().padding(top = 12.dp).clip(Shape16).background(colors.surface).border(1.dp, pal.line, Shape16).padding(16.dp), + ) { + Text("Set up Flatpak", fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground) + Text( + "Adds Flatpak to the Linux runtime and connects it to Flathub, about 20 MB. Apps then install " + + "with the runtimes they need; the first one is the biggest download.", + fontSize = 14.sp, color = colors.onSurfaceVariant, + ) + Actions { + PrimaryButton(if (busy) "Setting up…" else "Set up Flatpak", enabled = StoreState.busy == null, main = true) { StoreState.setup(ctx) } + } + } +} + +/** The running install, update or setup, whichever page it was started from. */ +@Composable +private fun BusyBar() { + val colors = MaterialTheme.colorScheme + val busy = StoreState.busy ?: return + val name = StoreState.installed.firstOrNull { it.id == busy }?.name ?: StoreState.details[busy]?.name + ?: when (busy) { "setup" -> "Flatpak"; "update-all" -> "Updates"; else -> busy.substringAfterLast('.') } + Column(modifier = Modifier.fillMaxWidth().padding(top = 10.dp, bottom = 4.dp)) { + val stage = StoreState.stage + Text( + "$name · " + (if (stage != null && StoreState.percent >= 0) "$stage · ${StoreState.percent}%" else stage ?: "Starting…"), + fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(bottom = 6.dp), + ) + if (StoreState.percent >= 0) LinearProgressIndicator(progress = { StoreState.percent / 100f }, modifier = Modifier.fillMaxWidth().height(4.dp)) + else LinearProgressIndicator(modifier = Modifier.fillMaxWidth().height(4.dp)) + } +} + +@Composable +private fun Discover(onOpen: (String) -> Unit, onCategory: (String) -> Unit) { + Rise(3) { SectionTitle("Categories", null) } + Rise(3) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(vertical = 4.dp)) { + FlathubApi.categories.forEach { c -> PillButton(c.label, selected = false) { onCategory(c.id) } } + } + } + if (StoreState.sectionsFailed && StoreState.sections.values.all { it.isEmpty() }) { + Rise(4) { Note("Flathub could not be reached. Check the connection and open the Store again.") } + Actions { SecondaryButton("Try again") { StoreState.loadSections(force = true) } } + return + } + StoreState.SECTIONS.forEachIndexed { i, (key, title) -> + val apps = StoreState.sections[key] + Rise(4 + i) { SectionTitle(title, apps?.let { if (it.isEmpty()) null else it.size.toString() } ?: "loading…") } + if (apps != null && apps.isNotEmpty()) Rise(4 + i) { AppGrid(apps.take(12), first = i == 0, onOpen = onOpen) } + } +} + +@Composable +private fun Search(query: String, category: String?, onQuery: (String) -> Unit, onCategory: (String?) -> Unit, onOpen: (String) -> Unit) { + val run = { StoreState.search(query.trim(), category) } + Rise(3) { + Row(verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(top = 12.dp)) { + AdbTextField( + value = query, onValueChange = onQuery, label = "Search Flathub", keyboardType = KeyboardType.Text, + imeAction = ImeAction.Done, placeholder = "Firefox, games, emulators…", modifier = Modifier.weight(1f), onDone = run, + ) + PrimaryButton("Search", main = true, onClick = run) + } + } + Rise(4) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(top = 12.dp, bottom = 4.dp)) { + PillButton("All", selected = category == null) { onCategory(null); StoreState.search(query.trim(), null) } + FlathubApi.categories.forEach { c -> + PillButton(c.label, selected = category == c.id) { onCategory(c.id); StoreState.search(query.trim(), c.id) } + } + } + } + val results = StoreState.searchResults + when { + StoreState.searching -> Rise(5) { SectionTitle("Searching…", null) } + results == null -> Rise(5) { Note("Search Flathub by name, or pick a category. Only apps built for ARM64 are listed.") } + results.isEmpty() -> Rise(5) { Note("Nothing on Flathub for ARM64 matches that.") } + else -> { + Rise(5) { SectionTitle("Results", results.size.toString()) } + Rise(6) { AppGrid(results, first = true, onOpen = onOpen) } + } + } +} + +@Composable +private fun Installed(a: FrontEndActions, onOpen: (String) -> Unit) { + val ctx = LocalContext.current + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val apps = StoreState.installed + Rise(3) { + Actions { + SecondaryButton(if (StoreState.checkingUpdates) "Checking…" else "Check for updates", enabled = StoreState.ready && !StoreState.checkingUpdates && StoreState.busy == null) { + StoreState.checkUpdates(ctx) + } + if (StoreState.updates.isNotEmpty()) PrimaryButton("Update all (${StoreState.updates.size})", enabled = StoreState.busy == null) { + StoreState.update(ctx, null, "Updates") + } + } + } + if (apps.isEmpty()) { + Rise(4) { Box(Modifier.padding(top = 12.dp)) { Note(if (StoreState.ready) "No apps yet. Find some under Discover or Search." else "Set up Flatpak to install apps.") } } + return + } + Rise(4) { SectionTitle("Installed", apps.size.toString()) } + Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + apps.forEachIndexed { i, app -> + key(app.id) { + Rise(5 + i.coerceAtMost(6)) { + Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14).padding(horizontal = 12.dp, vertical = 10.dp), + ) { + AppIcon(app.icon ?: StoreState.details[app.id]?.icon ?: FlathubApi.iconUrl(app.id), app.name, 44) + Column(modifier = Modifier.weight(1f)) { + Text(app.name, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + (if (app.id in StoreState.updates) "Update available · " else "") + (app.summary ?: app.id), + fontSize = 13.sp, color = if (app.id in StoreState.updates) pal.signal else colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, + ) + } + PrimaryButton("Open", compact = true, enabled = !s_busyFor(app.id)) { a.onFlatpakApp(app.id, app.name) } + if (app.id in StoreState.updates) SecondaryButton("Update", compact = true, enabled = StoreState.busy == null) { StoreState.update(ctx, app.id, app.name) } + SecondaryButton("Details", compact = true) { onOpen(app.id) } + } + } + } + } + } +} + +private fun s_busyFor(id: String) = StoreState.busy == id + +@Composable +private fun AppDetail(s: FrontEndState, a: FrontEndActions, id: String, onBack: () -> Unit) { + val ctx = LocalContext.current + val colors = MaterialTheme.colorScheme + val narrow = LocalNarrowPane.current + LaunchedEffect(id) { StoreState.loadDetails(id) } + val d = StoreState.details[id] + val local = StoreState.installed.firstOrNull { it.id == id } + val name = d?.name ?: local?.name ?: id + Rise(0) { BackLink("Store", onClick = onBack) } + Rise(1) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.padding(top = 12.dp, bottom = 8.dp)) { + AppIcon(d?.icon ?: local?.icon ?: FlathubApi.iconUrl(id), name, 72) + Column(modifier = Modifier.weight(1f)) { + Text(name, fontSize = if (narrow) 22.sp else 26.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 2, overflow = TextOverflow.Ellipsis) + d?.developer?.let { Text(it, fontSize = 14.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) } + (d?.summary ?: local?.summary)?.let { Text(it, fontSize = 14.sp, color = colors.onBackground, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 4.dp)) } + } + } + } + val busyHere = StoreState.busy == id + val installed = local != null + Rise(2) { + Actions { + if (installed) { + PrimaryButton("Open", enabled = !busyHere, main = true) { a.onFlatpakApp(id, name) } + if (id in StoreState.updates) SecondaryButton("Update", enabled = StoreState.busy == null) { StoreState.update(ctx, id, name) } + SecondaryButton(if (busyHere) "Working…" else "Remove", enabled = StoreState.busy == null) { StoreState.uninstall(ctx, id, name) } + } else { + PrimaryButton( + if (busyHere) "Installing…" else "Install", main = true, + enabled = StoreState.ready && StoreState.busy == null && s.ready && (d == null || d.arches.isEmpty() || "aarch64" in d.arches), + ) { StoreState.install(ctx, id, name) } + if (!StoreState.ready) ActionChip("Set up Flatpak first", ok = false) + } + d?.version?.let { ActionChip("v$it", ok = false) } + if (d != null && d.downloadSize > 0) ActionChip("${FileUtils.sizeToString(d.downloadSize)} download", ok = false) + if (installed) ActionChip("● Installed", ok = true) + } + } + if (busyHere) BusyBar() + if (d == null) { + Rise(3) { + Box(Modifier.padding(top = 14.dp)) { + Note(if (StoreState.detailsFailed[id] == true) "Flathub's page for this app could not be loaded." else "Loading from Flathub…") + } + } + return + } + if (d.arches.isNotEmpty() && "aarch64" !in d.arches) Rise(3) { Box(Modifier.padding(top = 12.dp)) { Note("Flathub has no ARM64 build of this app, so it cannot be installed here.") } } + if (d.screenshots.isNotEmpty()) { + Rise(3) { SectionTitle("Screenshots", null) } + Rise(3) { + Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState())) { + d.screenshots.take(6).forEach { shot -> + AsyncImage( + model = shot.thumb, contentDescription = null, contentScale = ContentScale.Crop, + modifier = Modifier.height(if (narrow) 150.dp else 190.dp).aspectRatio(16f / 9f).clip(Shape12).background(colors.surface), + ) + } + } + } + } + if (d.description.isNotEmpty()) { + Rise(4) { SectionTitle("About", null) } + Rise(4) { Text(d.description, fontSize = 14.sp, lineHeight = 20.sp, color = colors.onBackground, modifier = Modifier.fillMaxWidth()) } + } + Rise(5) { SectionTitle("Details", null) } + Rise(5) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.padding(bottom = 16.dp)) { + listOfNotNull( + "App ID" to d.id, + d.license?.let { "License" to it }, + d.runtime?.let { "Runtime" to it.replace("/x86_64/", "/aarch64/") }, + if (d.installedSize > 0) "Installed size" to FileUtils.sizeToString(d.installedSize) + " (plus its runtime, shared between apps)" else null, + d.homepage?.let { "Website" to it }, + ).forEach { (k, v) -> + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Text(k, fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.width(110.dp)) + Text(v, fontSize = 13.sp, color = colors.onBackground, modifier = Modifier.weight(1f)) + } + } + } + } +} + +/** Apps as wide tiles: icon, name, summary - three across, or a list on a narrow page. */ +@Composable +private fun AppGrid(apps: List, first: Boolean, onOpen: (String) -> Unit) { + val columns = if (LocalNarrowPane.current) 1 else 3 + Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 8.dp)) { + apps.chunked(columns).forEachIndexed { r, row -> + Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)) { + row.forEachIndexed { i, app -> + key(app.id) { + AppTile(app, Modifier.weight(1f).fillMaxHeight(), isFirst = first && r == 0 && i == 0) { onOpen(app.id) } + } + } + repeat(columns - row.size) { Spacer(Modifier.weight(1f)) } + } + } + } +} + +@Composable +private fun AppTile(app: FlathubApi.AppSummary, modifier: Modifier, isFirst: Boolean, onClick: () -> Unit) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + val pressed by src.collectIsPressedAsState() + val scale by animateFloatAsState(if (pressed) 0.97f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "appScale") + val installed = StoreState.installed.any { it.id == app.id } + Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = modifier.paneItem("tile:store:${app.id}").then(if (isFirst) Modifier.firstTile() else Modifier) + .graphicsLayer { scaleX = scale; scaleY = scale } + .clip(Shape14) + .background(if (hot) pal.signal.copy(alpha = 0.10f) else colors.surface) + .border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line, Shape14) + .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick) + .padding(horizontal = 12.dp, vertical = 10.dp), + ) { + AppIcon(app.icon, app.name, 44) + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Text(app.name, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false)) + if (app.verified) Icon(Icons.Outlined.Verified, contentDescription = "Verified", tint = pal.signal, modifier = Modifier.size(14.dp)) + } + Text(if (installed) "Installed" else app.summary, fontSize = 13.sp, color = if (installed) pal.good else colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } +} + +/** An app's icon from Flathub (a URL) or from its installed files; a placeholder until it loads. */ +@Composable +private fun AppIcon(model: Any?, name: String, size: Int) { + val colors = MaterialTheme.colorScheme + Box(contentAlignment = Alignment.Center, modifier = Modifier.size(size.dp).clip(RoundedCornerShape((size / 5).dp)).background(if (model == null) colors.surfaceVariant else Color.Transparent)) { + if (model == null) Icon(Icons.Outlined.Apps, contentDescription = null, tint = colors.onSurfaceVariant, modifier = Modifier.size((size / 2).dp)) + else AsyncImage(model = model, contentDescription = name, contentScale = ContentScale.Fit, modifier = Modifier.size(size.dp)) + } +} + +/** A category pill: small, focusable, and filled when it is the one in use. */ +@Composable +private fun PillButton(label: String, selected: Boolean, onClick: () -> Unit) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + val shape = RoundedCornerShape(99.dp) + Text( + label, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, + color = if (selected) pal.onSignal else if (hot) colors.onBackground else colors.onSurfaceVariant, + modifier = Modifier.paneItem("pill:$label").clip(shape) + .background(if (selected) pal.signal else if (hot) pal.signal.copy(alpha = 0.16f) else colors.surfaceVariant) + .border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line, shape) + .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick) + .controllerConfirm(onClick = onClick) + .padding(horizontal = 14.dp, vertical = 9.dp), + ) +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/WirelessAdbFixPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/WirelessAdbFixPage.kt index 2d944ab41..52cde55e8 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/WirelessAdbFixPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/WirelessAdbFixPage.kt @@ -344,7 +344,7 @@ private fun WirelessStepForm( } @Composable -private fun AdbTextField( +internal fun AdbTextField( value: String, onValueChange: (String) -> Unit, label: String, diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-bwrap b/tools/linuxfs/overlay/usr/local/bin/bannerlator-bwrap new file mode 100644 index 000000000..5a3a73951 --- /dev/null +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-bwrap @@ -0,0 +1,318 @@ +#!/usr/bin/python3 +# bubblewrap for Flatpak (FLATPAK_BWRAP), in a runtime that cannot have it. +# +# bwrap builds each sandbox from mount namespaces, which Android gives no app. proot gives the same +# picture - Flatpak's runtime as /usr, the app as /app, host paths bound where Flatpak asks - but a +# proot nested inside the session's proot runs every system call through two tracers, some thirty +# times slower on a program's start. So a sandbox is described to the app instead, over a socket +# only this app's uid may use, and the app starts it as a proot of its own beside the session's +# (BwrapSpawner). This process stands in for the sandbox until it ends: its output, its pid for +# --info-fd, and its exit status come back here. +# +# Sandboxes that remap nothing - Flatpak's install triggers (`--ro-bind / /` and the directory they +# update) and the D-Bus proxy (every top-level directory bound onto itself) - need no proot at all +# and run here directly, with the file descriptors Flatpak handed them. +import base64 +import json +import os +import signal +import socket +import struct +import sys + +SOCKET = "\0com.droiddeck.launcher.bwrap" +VERSION = "bubblewrap 0.11.0 (droiddeck)" + +# Options and how many arguments each takes; bwrap(1). +NO_ARG = { + "--unshare-user", "--unshare-user-try", "--unshare-ipc", "--unshare-pid", "--unshare-net", + "--unshare-uts", "--unshare-cgroup", "--unshare-cgroup-try", "--unshare-all", "--share-net", + "--disable-userns", "--assert-userns-disabled", "--clearenv", "--new-session", + "--die-with-parent", "--as-pid-1", "--level-prefix", "--tmp-overlay", "--ro-overlay", +} +ONE_ARG = { + "--args", "--argv0", "--userns", "--userns2", "--pidns", "--uid", "--gid", "--hostname", + "--chdir", "--unsetenv", "--lock-file", "--sync-fd", "--remount-ro", "--exec-label", + "--file-label", "--proc", "--dev", "--tmpfs", "--mqueue", "--dir", "--seccomp", + "--add-seccomp-fd", "--block-fd", "--userns-block-fd", "--info-fd", "--json-status-fd", + "--cap-add", "--cap-drop", "--perms", "--size", "--overlay-src", +} +TWO_ARG = { + "--setenv", "--bind", "--bind-try", "--dev-bind", "--dev-bind-try", "--ro-bind", + "--ro-bind-try", "--bind-fd", "--ro-bind-fd", "--file", "--bind-data", "--ro-bind-data", + "--symlink", "--chmod", +} +THREE_ARG = {"--overlay"} + + +def read_fd(fd): + chunks = [] + while True: + b = os.read(fd, 65536) + if not b: + break + chunks.append(b) + os.close(fd) + return b"".join(chunks) + + +def fd_path(fd): + return os.readlink("/proc/self/fd/%d" % fd) + + +def expand(argv): + """argv with every --args FD replaced by the NUL-separated arguments read from FD.""" + out = [] + i = 0 + while i < len(argv): + a = argv[i] + if a == "--args" and i + 1 < len(argv): + data = read_fd(int(argv[i + 1])) + # Each argument ends in a NUL; an empty one (--setenv LD_LIBRARY_PATH "") is kept. + parts = data.split(b"\0") + if parts and parts[-1] == b"": + parts.pop() + out.extend(x.decode("utf-8", "surrogateescape") for x in parts) + i += 2 + continue + out.append(a) + i += 1 + return out + + +class Spec: + def __init__(self): + self.ops = [] + self.env = dict(os.environ) + self.cwd = None + self.info_fds = [] + self.json_fds = [] + self.keep_fds = [] + self.command = [] + self.argv0 = None + + +def parse(argv): + s = Spec() + perms = None + i = 0 + while i < len(argv): + a = argv[i] + if a == "--": + s.command = argv[i + 1:] + break + if not a.startswith("--"): + s.command = argv[i:] + break + if a in ("--version",): + print(VERSION) + sys.exit(0) + if a == "--help": + print("usage: bwrap [OPTIONS...] [--] COMMAND [ARGS...]") + sys.exit(0) + n = 0 if a in NO_ARG else 1 if a in ONE_ARG else 2 if a in TWO_ARG else 3 if a in THREE_ARG else -1 + if n < 0: + sys.stderr.write("bwrap: Unknown option %s\n" % a) + sys.exit(1) + args = argv[i + 1:i + 1 + n] + if len(args) < n: + sys.stderr.write("bwrap: %s takes %d arguments\n" % (a, n)) + sys.exit(1) + i += 1 + n + op_perms, perms = perms, None + if a == "--perms": + perms = int(args[0], 8) + elif a == "--clearenv": + s.env = {"PWD": os.getcwd()} if "PWD" in os.environ else {} + elif a == "--setenv": + s.env[args[0]] = args[1] + elif a == "--unsetenv": + s.env.pop(args[0], None) + elif a == "--chdir": + s.cwd = args[0] + elif a == "--argv0": + s.argv0 = args[0] + elif a in ("--bind", "--bind-try", "--dev-bind", "--dev-bind-try", "--ro-bind", "--ro-bind-try"): + s.ops.append({"t": "bind", "src": args[0], "dest": args[1], "try": a.endswith("-try"), + "ro": a.startswith("--ro")}) + elif a in ("--bind-fd", "--ro-bind-fd"): + fd = int(args[0]) + s.ops.append({"t": "bind", "src": fd_path(fd), "dest": args[1], "try": False, + "ro": a.startswith("--ro")}) + os.close(fd) + elif a in ("--file", "--bind-data", "--ro-bind-data"): + s.ops.append({"t": "file", "dest": args[1], "data": read_fd(int(args[0])), + "perms": op_perms if op_perms is not None else 0o666 if a == "--file" else 0o600}) + elif a == "--symlink": + s.ops.append({"t": "symlink", "target": args[0], "dest": args[1]}) + elif a == "--dir": + s.ops.append({"t": "dir", "dest": args[0], "perms": op_perms if op_perms is not None else 0o755}) + elif a in ("--tmpfs", "--mqueue"): + s.ops.append({"t": "dir", "dest": args[0], "perms": op_perms if op_perms is not None else 0o755, + "empty": True}) + elif a == "--proc": + s.ops.append({"t": "proc", "dest": args[0]}) + elif a == "--dev": + s.ops.append({"t": "dev", "dest": args[0]}) + elif a in ("--seccomp", "--add-seccomp-fd"): + # proot is the sandbox; there is no filter to install under it. + os.close(int(args[0])) + elif a == "--block-fd": + read_fd(int(args[0])) + elif a == "--sync-fd": + s.keep_fds.append(int(args[0])) + elif a == "--info-fd": + s.info_fds.append(int(args[0])) + elif a == "--json-status-fd": + s.json_fds.append(int(args[0])) + # Everything else - namespaces, capabilities, labels, locks, --remount-ro, --chmod - has + # nothing to act on under proot. + if not s.command: + sys.stderr.write("bwrap: No command specified\n") + sys.exit(1) + return s + + +def real(path): + try: + return os.path.realpath(path) + except OSError: + return path + + +def remaps_nothing(s): + """True when every path in the sandbox is what it is out here already.""" + for op in s.ops: + t = op["t"] + if t == "bind": + src = real(op["src"]) + if not os.path.exists(src): + if op["try"]: + continue + return False + if src != real(op["dest"]) and not (op["src"] == "/" and op["dest"] == "/"): + return False + elif t == "symlink": + if not os.path.islink(op["dest"]) or os.readlink(op["dest"]) != op["target"]: + return False + elif t == "file": + # The D-Bus proxy's /.flatpak-info names the app to the bus; nothing here reads it. + if op["dest"] != "/.flatpak-info": + return False + elif t == "dir": + if not os.path.isdir(op["dest"]) or op.get("empty") and op["dest"] not in ("/tmp", "/var/tmp", "/run"): + return False + elif t in ("proc", "dev"): + if op["dest"] != "/" + t: + return False + return True + + +def report_pid(s, pid): + for fd in s.info_fds: + try: + os.write(fd, json.dumps({"child-pid": pid}).encode() + b"\n") + os.close(fd) + except OSError: + pass + for fd in s.json_fds: + try: + os.write(fd, json.dumps({"child-pid": pid}).encode() + b"\n") + except OSError: + pass + + +def run_here(s): + report_pid(s, os.getpid()) + for fd in s.json_fds: + os.close(fd) + if s.cwd: + os.chdir(s.cwd) + argv = list(s.command) + program = argv[0] + if s.argv0: + argv[0] = s.argv0 + try: + os.execvpe(program, argv, s.env) + except OSError as e: + sys.stderr.write("bwrap: execvp %s: %s\n" % (program, e.strerror)) + sys.exit(1) + + +def send(sock, kind, payload): + sock.sendall(kind + struct.pack(">I", len(payload)) + payload) + + +def recv_exact(sock, n): + buf = b"" + while len(buf) < n: + b = sock.recv(n - len(buf)) + if not b: + return None + buf += b + return buf + + +def run_by_app(s): + ops = [] + for op in s.ops: + op = dict(op) + if op["t"] == "bind": + # The app resolves no guest symlinks; hand it the path they lead to. + op["src"] = real(op["src"]) + if op["t"] == "file": + op["data"] = base64.b64encode(op["data"]).decode() + ops.append(op) + argv = list(s.command) + if s.argv0: + argv[0] = s.argv0 + request = {"argv": argv, "env": s.env, "cwd": s.cwd or "/", "ops": ops} + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + try: + sock.connect(SOCKET) + except OSError as e: + sys.stderr.write("bwrap: DroidDeck is not answering (%s); open DroidDeck and try again\n" % e) + sys.exit(1) + send(sock, b"R", json.dumps(request).encode()) + # This process is the sandbox as far as Flatpak is concerned: ended, it takes the sandbox down. + for sig in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP): + signal.signal(sig, lambda *_: os._exit(128 + 15)) + status = 1 + out = sys.stdout.buffer + while True: + head = recv_exact(sock, 5) + if head is None: + sys.stderr.write("bwrap: DroidDeck closed the sandbox\n") + break + kind, size = head[:1], struct.unpack(">I", head[1:])[0] + payload = recv_exact(sock, size) if size else b"" + if payload is None: + break + if kind == b"P": + report_pid(s, int(payload)) + elif kind == b"O": + out.write(payload) + out.flush() + elif kind == b"E": + sys.stderr.write("bwrap: %s\n" % payload.decode("utf-8", "replace")) + elif kind == b"X": + status = int(payload) + break + for fd in s.json_fds: + try: + os.write(fd, json.dumps({"exit-code": status}).encode() + b"\n") + except OSError: + pass + sys.exit(status) + + +def main(): + s = parse(expand(sys.argv[1:])) + if remaps_nothing(s): + run_here(s) + else: + run_by_app(s) + + +if __name__ == "__main__": + main() diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-flatpak b/tools/linuxfs/overlay/usr/local/bin/bannerlator-flatpak new file mode 100644 index 000000000..956d33f9e --- /dev/null +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-flatpak @@ -0,0 +1,186 @@ +#!/usr/bin/python3 +# The app's store talks to Flatpak through this: one JSON object per line on stdout, so progress +# reaches the front end as numbers rather than a terminal's redrawn bar. Per-user installation, +# Flathub as the remote, this device's architecture. +# +# bannerlator-flatpak install the app and whatever runtimes it needs +# bannerlator-flatpak uninstall the app, then runtimes nothing uses any more +# bannerlator-flatpak update [] one app, or everything with an update +# bannerlator-flatpak updates what has one (a "updates" line) +# +# Lines: {"e":"op","i":1,"n":3,"kind":"install","ref":"..."} as each step starts, +# {"e":"progress","i":1,"n":3,"percent":45,"status":"..."} while it runs, +# {"e":"error","message":"..."} and {"e":"done"} at the end. Exit status 0 on success. +import json +import os +import sys + +os.environ.setdefault("FLATPAK_BWRAP", "/usr/local/bin/bannerlator-bwrap") + +import gi # noqa: E402 + +gi.require_version("Flatpak", "1.0") +from gi.repository import Flatpak, GLib # noqa: E402 + +REMOTE = "flathub" + + +def emit(**kw): + sys.stdout.write(json.dumps(kw) + "\n") + sys.stdout.flush() + + +def installation(): + return Flatpak.Installation.new_user(None) + + +def app_ref(inst, app_id): + """The installed ref of an app, or None.""" + for ref in inst.list_installed_refs_by_kind(Flatpak.RefKind.APP, None): + if ref.get_name() == app_id: + return ref + return None + + +def transaction(inst): + t = Flatpak.Transaction.new_for_installation(inst, None) + t.set_no_interaction(True) + state = {"i": 0, "n": 0, "error": None} + + def on_ready(tr): + state["n"] = len(tr.get_operations()) + return True + + def on_new_operation(tr, op, progress): + state["i"] += 1 + emit(e="op", i=state["i"], n=state["n"], kind=op.get_operation_type().value_nick, ref=op.get_ref()) + progress.set_update_frequency(500) + + def changed(p): + emit(e="progress", i=state["i"], n=state["n"], percent=p.get_progress(), status=p.get_status() or "") + progress.connect("changed", changed) + + def on_operation_error(tr, op, err, details): + # A runtime extension that fails to install (a missing translation, an unneeded GL + # driver) is not worth losing the app over; anything else ends the transaction. + non_fatal = bool(details & Flatpak.TransactionErrorDetails.NON_FATAL) + emit(e="warning" if non_fatal else "error", message="%s: %s" % (op.get_ref(), err.message)) + if not non_fatal: + state["error"] = err.message + return non_fatal + + t.connect("ready", on_ready) + t.connect("new-operation", on_new_operation) + t.connect("operation-error", on_operation_error) + # A dependency found on more than one remote comes from the first (Flathub, the only one). + t.connect("choose-remote-for-ref", lambda tr, ref, runtime_ref, remotes: 0) + t.connect("end-of-lifed-with-rebase", lambda *a: False) + t.connect("add-new-remote", lambda *a: False) + return t, state + + +def run(t, state): + try: + t.run(None) + except GLib.Error as err: + if state["error"] is None and not err.matches(Flatpak.error_quark(), Flatpak.Error.ABORTED): + state["error"] = err.message + if state["error"]: + emit(e="error", message=state["error"]) + return 1 + emit(e="done") + return 0 + + +def install(app_id): + inst = installation() + arch = Flatpak.get_default_arch() + if app_ref(inst, app_id) is not None: + emit(e="done", note="already installed") + return 0 + try: + remote_ref = inst.fetch_remote_ref_sync(REMOTE, Flatpak.RefKind.APP, app_id, arch, None, None) + branch = remote_ref.get_branch() + except GLib.Error: + emit(e="error", message="%s is not on Flathub for %s" % (app_id, arch)) + return 1 + t, state = transaction(inst) + t.add_install(REMOTE, "app/%s/%s/%s" % (app_id, arch, branch), None) + return run(t, state) + + +def uninstall(app_id): + inst = installation() + ref = app_ref(inst, app_id) + if ref is None: + emit(e="done", note="not installed") + return 0 + t, state = transaction(inst) + t.add_uninstall(ref.format_ref()) + rc = run(t, state) + if rc == 0: + # Runtimes only this app used; Flatpak's own `uninstall --unused`. + try: + unused = inst.list_unused_refs(None, None) + except GLib.Error: + unused = [] + if unused: + t, state = transaction(inst) + for r in unused: + t.add_uninstall(r.format_ref()) + run(t, state) + return rc + + +def updatable(inst): + try: + return inst.list_installed_refs_for_update(None) + except GLib.Error as err: + emit(e="error", message=err.message) + return None + + +def update(app_id=None): + inst = installation() + refs = updatable(inst) + if refs is None: + return 1 + if app_id: + refs = [r for r in refs if r.get_name() == app_id or r.get_kind() == Flatpak.RefKind.RUNTIME] + if not refs: + emit(e="done", note="up to date") + return 0 + t, state = transaction(inst) + for r in refs: + t.add_update(r.format_ref(), None, None) + return run(t, state) + + +def updates(): + refs = updatable(installation()) + if refs is None: + return 1 + emit(e="updates", apps=sorted({r.get_name() for r in refs if r.get_kind() == Flatpak.RefKind.APP}), + runtimes=len([r for r in refs if r.get_kind() == Flatpak.RefKind.RUNTIME])) + return 0 + + +def main(): + if len(sys.argv) < 2: + sys.stderr.write(__doc__ or "usage: bannerlator-flatpak install|uninstall|update|updates [app-id]\n") + return 2 + cmd, args = sys.argv[1], sys.argv[2:] + if cmd == "install" and args: + return install(args[0]) + if cmd == "uninstall" and args: + return uninstall(args[0]) + if cmd == "update": + return update(args[0] if args else None) + if cmd == "updates": + return updates() + sys.stderr.write("usage: bannerlator-flatpak install|uninstall|update|updates [app-id]\n") + return 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-flatpak-run b/tools/linuxfs/overlay/usr/local/bin/bannerlator-flatpak-run new file mode 100644 index 000000000..87f30a754 --- /dev/null +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-flatpak-run @@ -0,0 +1,23 @@ +#!/bin/bash +# A Flatpak app from the front end, full screen under gamescope like an emulator: +# bannerlator-flatpak-run [args...] +set -u +export FLATPAK_BWRAP=/usr/local/bin/bannerlator-bwrap +case ":${XDG_DATA_DIRS:-}:" in + *"/flatpak/exports/share:"*) ;; + *) export XDG_DATA_DIRS=$HOME/.local/share/flatpak/exports/share:${XDG_DATA_DIRS:-/usr/local/share:/usr/share} ;; +esac +# gamescope manages X windows. Flatpak would hand an app the app's own Wayland socket instead (it +# finds wayland-0 in the runtime directory even with WAYLAND_DISPLAY unset), and the window would +# open behind gamescope - or not at all, with SDL told to use X11. So under gamescope: X11 only. +socket=() +if [ -n "${BL_INSIDE:-}" ]; then + unset WAYLAND_DISPLAY + socket=(--nosocket=wayland --socket=x11) +fi +echo "== flatpak run ${socket[*]} $*" +# Apps expect a session bus (settings, portals, single-instance checks); a run mode has none. +if [ -z "${DBUS_SESSION_BUS_ADDRESS:-}" ] && command -v dbus-run-session >/dev/null 2>&1; then + exec dbus-run-session -- flatpak run "${socket[@]}" "$@" +fi +exec flatpak run "${socket[@]}" "$@" diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-flatpak-setup b/tools/linuxfs/overlay/usr/local/bin/bannerlator-flatpak-setup new file mode 100644 index 000000000..a15deaaa2 --- /dev/null +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-flatpak-setup @@ -0,0 +1,83 @@ +#!/bin/bash +# Puts Flatpak into the runtime, with Flathub as a per-user remote. Run by the app under proot's +# fake root (pacman and pacman-key refuse any other uid), outside a session. +# +# The runtime's pacman database lists only the base image: the desktop and emulator packages were +# unpacked over it without registering. Asked for flatpak the usual way, pacman "reinstalls" about +# a hundred of those - Mesa among them, over the runtime's own Turnip build with its KGSL backend - +# and then runs every hook in the image, mkinitcpio included. So pacman only fetches and checks the +# few packages Flatpak needs on top of this image, verifying each against the Arch Linux ARM +# keyring, and they are unpacked here without hooks or scripts, like the desktop package itself. +# +# bannerlator-flatpak-setup install or repair; prints "== STEP ..." milestones +set -u +export LANG=C.UTF-8 +step() { echo "== STEP $*"; } +fail() { echo "== FAIL $*"; exit 1; } + +# Flatpak, the libraries it and appstream add to this image, and the D-Bus proxy flatpak run puts +# between an app and the session bus. bubblewrap is not among them: Android gives an app no user +# namespaces, and bannerlator-bwrap stands in for it (FLATPAK_BWRAP). +PACKAGES="flatpak ostree appstream libxmlb libmalcontent libfyaml accountsservice composefs xdg-dbus-proxy" +WORK=/var/cache/droiddeck-flatpak +CONF=$WORK/pacman.conf +mkdir -p "$WORK/pkg" "$WORK/db" || fail "cannot create $WORK" + +# The image's pacman.conf names a download user this rootfs does not have (only root exists) and a +# sandbox proot cannot set up. Its one mirror is plain HTTP; these serve the same repos over TLS. +{ for m in https://ca.us.mirror.archlinuxarm.org https://fl.us.mirror.archlinuxarm.org \ + https://de3.mirror.archlinuxarm.org https://nl.mirror.archlinuxarm.org; do + echo "Server = $m/\$arch/\$repo"; done + grep '^Server' /etc/pacman.d/mirrorlist 2>/dev/null; } > "$WORK/mirrorlist" +sed -e 's/^DownloadUser/#DownloadUser/' \ + -e 's/^\[options\]/[options]\nDisableSandbox/' \ + -e "s#^Include *= */etc/pacman.d/mirrorlist#Include = $WORK/mirrorlist#" \ + /etc/pacman.conf > "$CONF" || fail "no /etc/pacman.conf" + +if [ ! -f /etc/pacman.d/gnupg/pubring.gpg ] && [ ! -f /etc/pacman.d/gnupg/pubring.kbx ]; then + step "Preparing the package keyring" + pacman-key --init >/dev/null 2>&1 || fail "pacman-key --init failed" + pacman-key --populate archlinuxarm >/dev/null 2>&1 || fail "pacman-key --populate failed" +fi + +# A database of our own, so the image's stays exactly as it was built: -Sy on it would leave the +# rest of the image a partial upgrade behind the repos. +step "Reading the package lists" +pacman --config "$CONF" --dbpath "$WORK/db" --noconfirm -Sy >/dev/null 2>&1 \ + || pacman --config "$CONF" --dbpath "$WORK/db" --noconfirm -Sy 2>&1 | tail -3 \ + || fail "could not reach the Arch Linux ARM mirrors" +[ -f "$WORK/db/sync/extra.db" ] || fail "could not reach the Arch Linux ARM mirrors" + +step "Downloading Flatpak" +# -w only downloads; pacman still checks each package's signature against the keyring. -dd: the +# dependencies are already in the image (and checked below by what the binaries actually link). +out=$(pacman --config "$CONF" --dbpath "$WORK/db" --cachedir "$WORK/pkg" --noconfirm -Sw -dd $PACKAGES 2>&1) +rc=$? +echo "$out" | grep -vE '^\s*$' | tail -4 +[ $rc -eq 0 ] || fail "download failed" + +step "Installing Flatpak" +for name in $PACKAGES; do + ver=$(pacman --config "$CONF" --dbpath "$WORK/db" -Sp --print-format '%v' -dd "$name" 2>/dev/null | tail -1) + file=$(ls "$WORK/pkg/$name-$ver-"*.pkg.tar.* 2>/dev/null | grep -v '\.sig$' | head -1) + [ -n "$file" ] || fail "no package file for $name $ver" + bsdtar -xpf "$file" -C / --exclude .PKGINFO --exclude .BUILDINFO --exclude .MTREE --exclude .INSTALL \ + || fail "could not unpack $name" + echo "$name $ver" >> "$WORK/installed.new" +done +mv -f "$WORK/installed.new" "$WORK/installed" +# Only the newest file of each package is worth keeping for a repair. +find "$WORK/pkg" -type f -mtime +1 -delete 2>/dev/null + +# What the binaries link is the real dependency list: anything this image lacks shows here. +missing=$(for f in /usr/bin/flatpak /usr/lib/libflatpak.so.0 /usr/lib/libostree-1.so.1 /usr/bin/xdg-dbus-proxy; do + ldd "$f" 2>&1 | grep -E 'not found|version .* not found' +done | sort -u) +[ -z "$missing" ] || { echo "$missing"; fail "Flatpak is missing libraries"; } +flatpak --version >/dev/null 2>&1 || fail "flatpak does not start" + +step "Adding Flathub" +export FLATPAK_BWRAP=/usr/local/bin/bannerlator-bwrap +flatpak remote-add --user --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo 2>&1 \ + || fail "could not add the Flathub remote" +echo "== DONE $(flatpak --version)" diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session index b7fdc0a55..fa71328f9 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session @@ -81,6 +81,10 @@ export XDG_DATA_HOME=${XDG_DATA_HOME:-$HOME/.local/share} export XDG_CONFIG_HOME=${XDG_CONFIG_HOME:-$HOME/.config} export XDG_CACHE_HOME=${XDG_CACHE_HOME:-$HOME/.cache} mkdir -p "$XDG_DATA_HOME" "$XDG_CONFIG_HOME" "$XDG_CACHE_HOME" +# Flatpak (the store's apps): its sandboxes through the app instead of bubblewrap, which needs the +# user namespaces Android keeps from apps, and its apps' menu entries and icons on the desktop. +export FLATPAK_BWRAP=/usr/local/bin/bannerlator-bwrap +export XDG_DATA_DIRS=$XDG_DATA_HOME/flatpak/exports/share:${XDG_DATA_DIRS:-/usr/local/share:/usr/share} # The cores a program runs on outside Steam (the desktop and everything started from it, a # program from the rail): the user's game cores when set (BL_GAME_CPUS), else every core but the diff --git a/tools/proot/patches/0010-new-mount-api-enosys.patch b/tools/proot/patches/0010-new-mount-api-enosys.patch new file mode 100644 index 000000000..87fd43d1f --- /dev/null +++ b/tools/proot/patches/0010-new-mount-api-enosys.patch @@ -0,0 +1,105 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: DroidDeck +Date: Tue, 29 Sep 2026 08:00:00 -0400 +Subject: [PATCH] syscall: answer the new mount API with ENOSYS + +open_tree(AT_FDCWD, "/") and its relatives take paths proot never +translates, so libglnx (Flatpak) got a descriptor for the host root and +created directories there. Unsupported, callers fall back to openat. +--- +diff --git a/src/syscall/enter.c b/src/syscall/enter.c +index 1d3b3fd..32d2503 100644 +--- a/src/syscall/enter.c ++++ b/src/syscall/enter.c +@@ -465,6 +465,15 @@ int translate_syscall_enter(Tracee *tracee) + status = -ENOSYS; + break; + ++ /* The new mount API opens and moves paths proot never sees: open_tree(AT_FDCWD, "/") ++ * hands back the host's root. Callers (libglnx, systemd) fall back to openat. */ ++ case PR_open_tree: ++ case PR_move_mount: ++ case PR_fspick: ++ case PR_mount_setattr: ++ status = -ENOSYS; ++ break; ++ + case PR_inotify_add_watch: + flags = peek_reg(tracee, CURRENT, SYSARG_3); + +diff --git a/src/syscall/seccomp.c b/src/syscall/seccomp.c +index 0b381bb..6ba7b67 100644 +--- a/src/syscall/seccomp.c ++++ b/src/syscall/seccomp.c +@@ -457,6 +457,10 @@ static FilteredSysnum proot_sysnums[] = { + { PR_open, 0 }, + { PR_openat, 0 }, + { PR_openat2, 0 }, ++ { PR_open_tree, 0 }, ++ { PR_move_mount, 0 }, ++ { PR_fspick, 0 }, ++ { PR_mount_setattr, 0 }, + { PR_pivot_root, 0 }, + { PR_prctl, 0 }, + { PR_prlimit64, FILTER_SYSEXIT }, +diff --git a/src/syscall/sysnums-arm.h b/src/syscall/sysnums-arm.h +index e3f6f6b..ebf0afc 100644 +--- a/src/syscall/sysnums-arm.h ++++ b/src/syscall/sysnums-arm.h +@@ -358,6 +358,10 @@ static const Sysnum sysnums_arm[] = { + [ 397 ] = PR_statx, + [ 439 ] = PR_faccessat2, + [ 435 ] = PR_clone3, ++ [ 428 ] = PR_open_tree, ++ [ 429 ] = PR_move_mount, ++ [ 433 ] = PR_fspick, + [ 437 ] = PR_openat2, ++ [ 442 ] = PR_mount_setattr, + [ 452 ] = PR_fchmodat2, + }; +diff --git a/src/syscall/sysnums-arm64.h b/src/syscall/sysnums-arm64.h +index b257339..c68a1f8 100644 +--- a/src/syscall/sysnums-arm64.h ++++ b/src/syscall/sysnums-arm64.h +@@ -278,8 +278,12 @@ static const Sysnum sysnums_arm64[] = { + [ 289 ] = PR_pkey_alloc, + [ 290 ] = PR_pkey_free, + [ 291 ] = PR_statx, ++ [ 428 ] = PR_open_tree, ++ [ 429 ] = PR_move_mount, ++ [ 433 ] = PR_fspick, + [ 435 ] = PR_clone3, + [ 437 ] = PR_openat2, ++ [ 442 ] = PR_mount_setattr, + [ 439 ] = PR_faccessat2, + [ 452 ] = PR_fchmodat2, + }; +diff --git a/src/syscall/sysnums.list b/src/syscall/sysnums.list +index c221ddd..93ea2e2 100644 +--- a/src/syscall/sysnums.list ++++ b/src/syscall/sysnums.list +@@ -86,6 +86,7 @@ SYSNUM(flock) + SYSNUM(fork) + SYSNUM(fremovexattr) + SYSNUM(fsetxattr) ++SYSNUM(fspick) + SYSNUM(fstat) + SYSNUM(fstat64) + SYSNUM(fstatat64) +@@ -191,6 +192,8 @@ SYSNUM(mmap) + SYSNUM(mmap2) + SYSNUM(modify_ldt) + SYSNUM(mount) ++SYSNUM(mount_setattr) ++SYSNUM(move_mount) + SYSNUM(move_pages) + SYSNUM(mprotect) + SYSNUM(mpx) +@@ -221,6 +224,7 @@ SYSNUM(oldstat) + SYSNUM(olduname) + SYSNUM(open) + SYSNUM(open_by_handle_at) ++SYSNUM(open_tree) + SYSNUM(openat) + SYSNUM(openat2) + SYSNUM(pause)