feat: run Flatpak apps through a proot bwrap stand-in and add a Flathub store

This commit is contained in:
xXJsonDeruloXx committed 2026-09-29 08:06:50 -04:00
1 parent 9644a4cc7f
commit c63355be6c
22 files changed
+2106 -13

No files matched your search

@@ -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)
}
}
@@ -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.
@@ -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)
@@ -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<String, String>()
/**
* Whether a program from the rail runs at the panel's own resolution rather than the session's
@@ -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<Bind>, val cwd: String, val argv: List<String>, val env: Map<String, String>)
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<Bind>, 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<Bind>, rootfs: String, src: String, dest: String): List<Bind> {
val out = ArrayList<Bind>()
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<Bind>()
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<String, String>()
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<String>()
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()
}
}
@@ -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<App> {
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<String>, fakeRoot: Boolean, onLine: (String) -> Unit): Int {
val root = LinuxRuntime.rootDir(context)
LinuxRuntime.writeAccounts(context)
SessionFiles.stage(context, root)
val runtimeDir = File(context.filesDir, ".flatpak-rt").apply { mkdirs() }
val cmd = LinuxRuntime.prootPrefix(context, root, "/root", fakeRoot)
LinuxRuntime.binds(context, null, runtimeDir, Environment.getExternalStorageDirectory(), null)
.forEach { cmd.add("-b"); cmd.add(it) }
cmd += listOf(
"/usr/bin/env", "-i", "HOME=/root", "USER=root", "LANG=C.UTF-8",
"PATH=/usr/local/bin:/usr/bin:/bin", "XDG_RUNTIME_DIR=${runtimeDir.path}",
"XDG_DATA_HOME=/root/.local/share", "FLATPAK_BWRAP=$BWRAP",
)
cmd += argv
val builder = ProcessBuilder(cmd).directory(root).redirectErrorStream(true)
builder.environment().apply {
put("PROOT_LOADER", LinuxRuntime.prootLoader(context).path)
put("PROOT_TMP_DIR", context.cacheDir.path)
if (SessionPrefs.prootNoSeccomp(context)) put("PROOT_NO_SECCOMP", "1")
LinuxRuntime.prootLibraryPath(context).takeIf { it.isNotEmpty() }?.let { put("LD_LIBRARY_PATH", it) }
}
val process = builder.start()
val pid = HostProcess.pidOf(process)
OrphanReaper.keep(pid)
try {
process.inputStream.bufferedReader().useLines { lines -> lines.forEach(onLine) }
return process.waitFor()
} finally {
OrphanReaper.release(pid)
}
}
private inline fun <T> 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<String>? {
if (!ready(context)) return emptySet()
var apps: Set<String>? = 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
}
}
@@ -115,6 +115,37 @@ public final class LinuxRuntime {
File externalStorage, List<String> extraBinds,
List<String> guestCommand) {
File root = rootDir(context);
List<String> 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<String> 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<String> lastBinds;
public static List<String> lastBinds(Context context) {
List<String> 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<String> 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<String> prootPrefix(Context context, File root, String cwd, boolean fakeRoot) {
List<String> 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<String> binds(Context context, File sessionRoot, File runtimeDir,
File externalStorage, List<String> extraBinds) {
File root = rootDir(context);
List<String> 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<String> 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\\";
@@ -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<Int>()
/** 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/<pid>/stat. */
private fun descendants(roots: Set<Int>): Set<Int> {
if (roots.isEmpty()) return emptySet()
val parent = HashMap<Int, Int>()
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 ->
@@ -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",
@@ -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<Screenshot>, val categories: List<String>,
val runtime: String?, val downloadSize: Long, val installedSize: Long, val arches: List<String>,
)
/** 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<AppSummary>? {
// 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<AppSummary>? {
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<AppSummary>? = 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)<li>\\s*"), "• ")
.replace(Regex("(?i)</(p|li|ul|ol)>"), "\n")
.replace(Regex("<[^>]+>"), "")
.replace("&amp;", "&").replace("&lt;", "<").replace("&gt;", ">").replace("&quot;", "\"").replace("&#39;", "'")
.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()
}
}
}
@@ -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<List<FlatpakManager.App>>(emptyList())
private set
var updates by mutableStateOf<Set<String>>(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<String?>(null)
private set
var stage by mutableStateOf<String?>(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<String, List<FlathubApi.AppSummary>>()
var sectionsFailed by mutableStateOf(false)
private set
val details = mutableStateMapOf<String, FlathubApi.AppDetails>()
val detailsFailed = mutableStateMapOf<String, Boolean>()
var searchResults by mutableStateOf<List<FlathubApi.AppSummary>?>(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()
}
}
@@ -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)
@@ -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") }
@@ -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,
@@ -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<String?>(null) }
var query by rememberSaveable { mutableStateOf("") }
var category by rememberSaveable { mutableStateOf<String?>(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<FlathubApi.AppSummary>, 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),
)
}
@@ -344,7 +344,7 @@ private fun WirelessStepForm(
}
@Composable
private fun AdbTextField(
internal fun AdbTextField(
value: String,
onValueChange: (String) -> Unit,
label: String,
@@ -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()
@@ -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 <app-id> the app and whatever runtimes it needs
# bannerlator-flatpak uninstall <app-id> the app, then runtimes nothing uses any more
# bannerlator-flatpak update [<app-id>] 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())
@@ -0,0 +1,23 @@
#!/bin/bash
# A Flatpak app from the front end, full screen under gamescope like an emulator:
# bannerlator-flatpak-run <app-id> [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[@]}" "$@"
@@ -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)"
@@ -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
@@ -0,0 +1,105 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: DroidDeck <noreply@droiddeck.invalid>
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)