Compare commits

...
Author SHA1 Message Date
Kurt Himebauch 6ece38ddb8 Merge branch 'main' into feat/flatpak-support 2026-09-30 00:35:21 -04:00
xXJsonDeruloXx 9a4eeb3d06 feat: put the Flathub Store and AppImage import behind beta toggles in Setup 2026-09-30 00:08:57 -04:00
xXJsonDeruloXx 85add46809 docs: AppImage import and what x86 apps would take 2026-09-30 00:00:05 -04:00
xXJsonDeruloXx b78b06b78d fix: keep failed Flatpak extensions non-fatal, give Electron AppImages a bus, X11 and time to relaunch 2026-09-29 23:59:07 -04:00
xXJsonDeruloXx 58ec3609b6 Merge remote-tracking branch 'origin/main' into feat/flatpak-support 2026-09-29 22:59:11 -04:00
MaxsTechReview abb3f5582b Merge branch 'main' into feat/flatpak-support 2026-09-29 20:43:37 -04:00
xXJsonDeruloXx f5dfc946f2 Merge main into PR #77 2026-09-29 18:28:48 -04:00
Kurt Himebauch a38952378d Merge pull request #82 from maxjivi05/fix/droiddeck-flatpak-fix 2026-09-29 15:32:45 -04:00
maxjivi05 29dc88dce1 fix: point store commands at the current network's DNS 2026-09-29 15:23:29 -04:00
maxjivi05 56889fe2ba fix: let Flatpak create its repo where Android denies hard links 2026-09-29 15:04:07 -04:00
xXJsonDeruloXx 463291d655 fix: report failed Flatpak installs as failures and deploy runtimes that ship a resolv.conf link 2026-09-29 13:54:02 -04:00
xXJsonDeruloXx b0e02124d8 feat: import ARM64 AppImages from storage and run them from the Desktop page 2026-09-29 13:33:29 -04:00
xXJsonDeruloXx 34640e83cb fix: add Flathub without a download, log store commands, keep Electron apps on X11 under gamescope 2026-09-29 13:17:09 -04:00
MaxsTechReview 4e9cc0bb36 Merge branch 'main' into feat/flatpak-support 2026-09-29 10:50:25 -04:00
xXJsonDeruloXx 5c1b29f47a fix: fetch Flatpak's supporting libraries when the runtime lacks them 2026-09-29 09:03:44 -04:00
xXJsonDeruloXx 672b5dc74c fix: let Chromium, Electron and Firefox Flatpaks start without their own sandboxes 2026-09-29 08:57:24 -04:00
xXJsonDeruloXx 76c82ee3ff fix: keep controller focus through Store tabs and app pages; show progress per Flatpak step 2026-09-29 08:46:45 -04:00
xXJsonDeruloXx 15a541d745 test: cover the sandbox planner and store helpers; docs: explain Flatpak on DroidDeck 2026-09-29 08:39:30 -04:00
xXJsonDeruloXx fe04315461 feat: Flatpak games on the desktop, Store apps on the Desktop page, safer remove 2026-09-29 08:29:09 -04:00
xXJsonDeruloXx 4fef38e87e feat: give Flatpak apps the KGSL GPU, the controllers and a CPU LLVM can target 2026-09-29 08:18:26 -04:00
xXJsonDeruloXx c63355be6c feat: run Flatpak apps through a proot bwrap stand-in and add a Flathub store 2026-09-29 08:06:50 -04:00
38 changed files with 3293 additions and 20 deletions

No files matched your search

+1 -1
View File
@@ -11,7 +11,7 @@ DroidDeck brings the SteamOS experience to Android: Valve's Steam client in Big
## Requirements and install
Use Android 9 or newer on a supported Adreno device (730 or newer, or 8xx). Mali, Xclipse, PowerVR, and Adreno 710 are unsupported. No root is required. Allow about 3 GB for the runtime and 1.1 GB more for the desktop and emulators. Install the APK from [Releases](https://github.com/The412Banner/DroidDeck/releases), install the Linux runtime, then press **Play** and sign in. Steam downloads on first launch. Install **Desktop & apps** to use the desktop and emulators.
Use Android 9 or newer on a supported Adreno device (730 or newer, or 8xx). Mali, Xclipse, PowerVR, and Adreno 710 are unsupported. No root is required. Allow about 3 GB for the runtime and 1.1 GB more for the desktop and emulators. Install the APK from [Releases](https://github.com/The412Banner/DroidDeck/releases), install the Linux runtime, then press **Play** and sign in. Steam downloads on first launch. Install **Desktop & apps** to use the desktop and emulators. The **Store** installs Linux apps and games from Flathub (ARM64 builds) with Flatpak; see [docs/development/flatpak.md](docs/development/flatpak.md).
On Android 12+, if Steam exits without a log, turn off **Restrict child processes** in Developer options.
@@ -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)
}
}
@@ -142,6 +142,8 @@ class MainActivity : ComponentActivity() {
private var clientDirectAudio by mutableStateOf(false)
private var forceFullscreen by mutableStateOf(true)
private var launcherFullscreen by mutableStateOf(true)
private var storeEnabled by mutableStateOf(false)
private var appImagesEnabled by mutableStateOf(false)
private var mic by mutableStateOf(false)
// The app's own picker (files/), once per kind of pick: the two driver lists validate
@@ -214,6 +216,9 @@ class MainActivity : ComponentActivity() {
private var addedGamesArt by mutableStateOf(true)
@Volatile private var artFetchRunning = false
private var addedGames by mutableStateOf<List<com.droiddeck.launcher.ui.AddedGameRow>>(emptyList())
private val pickAppImage = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { com.droiddeck.launcher.store.AppImageState.import(this, it) }
}
private val pickRomsDir = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedPath(r.data)?.let { path ->
SessionPrefs.setRomsDir(this, path)
@@ -306,6 +311,8 @@ class MainActivity : ComponentActivity() {
theme = SessionPrefs.theme(this)
backActionsInverted = SessionPrefs.backActionsInverted(this)
launcherFullscreen = SessionPrefs.launcherFullscreen(this)
storeEnabled = SessionPrefs.storeEnabled(this)
appImagesEnabled = SessionPrefs.appImagesEnabled(this)
applyLauncherFullscreen()
setContent {
DroidDeckTheme(theme) {
@@ -349,6 +356,8 @@ class MainActivity : ComponentActivity() {
phantomProcessStatus = phantomProcessStatus,
showPhantomGate = showPhantomGate,
launcherFullscreen = launcherFullscreen,
storeEnabled = storeEnabled,
appImagesEnabled = appImagesEnabled,
),
FrontEndActions(
onPlay = { startSteamSession() },
@@ -368,6 +377,25 @@ 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.
onImportAppImage = {
pickAppImage.launch(InAppFilePicker.buildIntent(this, listOf("appimage"), "Choose an AppImage"))
},
// An imported AppImage, full screen under gamescope like an emulator.
onAppImage = { dir, name ->
Library.flatpakNames[dir] = name
startActivity(Intent(this, SessionActivity::class.java)
.putExtra(SessionService.EXTRA_MODE, SessionService.MODE_RUN)
.putExtra(SessionService.EXTRA_PROGRAM, com.droiddeck.launcher.runtime.AppImageManager.LAUNCHER)
.putExtra(SessionService.EXTRA_PROGRAM_ARGS, arrayOf(dir)))
},
onFlatpakApp = { id, name ->
Library.flatpakNames[id] = name
startActivity(Intent(this, SessionActivity::class.java)
.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)
@@ -423,6 +451,8 @@ class MainActivity : ComponentActivity() {
launcherFullscreen = on
applyLauncherFullscreen()
},
onStoreEnabled = { on -> SessionPrefs.setStoreEnabled(this, on); storeEnabled = on },
onAppImagesEnabled = { on -> SessionPrefs.setAppImagesEnabled(this, on); appImagesEnabled = on },
onHomeApp = { manageHomeApp() },
onHomeScreen = { on ->
HomeApp.setHomeScreenEnabled(this, on)
@@ -1076,7 +1106,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.
@@ -949,8 +949,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
return
}
// It stays until the user leaves: a failure read against a timer is a failure not read.
val what = when (SessionState.mode) {
SessionService.MODE_RUN -> com.droiddeck.launcher.frontend.Library.nameForProgram(SessionState.program) ?: "The program"
SessionService.MODE_DESKTOP -> "The desktop"
else -> "Steam"
}
loading.showEnded(
hint ?: "Steam stopped unexpectedly. Share the logs with a bug report, or try again.",
hint ?: "$what stopped unexpectedly. Share the logs with a bug report, or try again.",
"Exit status $status · ${SessionState.logFile?.path ?: "no log"}",
)
focusEndedScreen()
@@ -104,6 +104,7 @@ public final class FileUtils {
public static String sizeToString(long bytes) {
if (bytes >= 1L << 30) return String.format(java.util.Locale.US, "%.1f GB", bytes / (float) (1L << 30));
if (bytes >= 1L << 20) return String.format(java.util.Locale.US, "%.0f MB", bytes / (float) (1L << 20));
if (bytes >= 1L << 10) return String.format(java.util.Locale.US, "%.0f KB", bytes / (float) (1L << 10));
return bytes + " B";
}
}
@@ -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)
@@ -166,7 +166,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 || program == com.droiddeck.launcher.runtime.AppImageManager.LAUNCHER) {
com.droiddeck.launcher.session.SessionState.programArgs.firstOrNull()?.let { flatpakNames[it] ?: it.substringAfterLast('.') }
} else specs.firstOrNull { it.program == program }?.name
/** Flatpak apps' and AppImages' names by id or directory, as launched: the session only knows that. */
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,181 @@
package com.droiddeck.launcher.runtime
import android.content.Context
import android.os.StatFs
import android.util.Log
import com.droiddeck.launcher.core.FileUtils
import java.io.File
import java.io.RandomAccessFile
/**
* AppImages the user brings: picked from storage, checked, and extracted once into the runtime at
* /opt/appimages/user/<id>/app. proot has no FUSE to mount an image, and extracting it at every
* start would cost its whole size in /tmp each time. Each one gets a menu entry on the Linux
* desktop, and the front end starts it under gamescope through bannerlator-appimage-run.
*
* <id>/name holds the name to show, <id>/icon.png the icon when the image has a PNG one.
*/
object AppImageManager {
private const val TAG = "AppImageManager"
const val GUEST_DIR = "/opt/appimages/user"
const val LAUNCHER = "/usr/local/bin/bannerlator-appimage-run"
private const val ELF_AARCH64 = 183
private const val ELF_X86_64 = 62
class Item(val id: String, val name: String, val icon: File?, val comment: String?) {
val guestDir: String get() = "$GUEST_DIR/$id"
}
private fun root(context: Context) = File(LinuxRuntime.rootDir(context), GUEST_DIR.substring(1))
fun list(context: Context): List<Item> =
root(context).listFiles()?.filter { File(it, "app/AppRun").exists() || Files.isLink(File(it, "app/AppRun")) }
?.map { dir ->
Item(
dir.name,
FileUtils.readString(File(dir, "name"))?.trim()?.takeIf { it.isNotEmpty() } ?: dir.name,
File(dir, "icon.png").takeIf { it.isFile },
FileUtils.readString(File(dir, "comment"))?.trim()?.takeIf { it.isNotEmpty() },
)
}?.sortedBy { it.name.lowercase() } ?: emptyList()
/**
* What is wrong with [file] as an AppImage for this device, or null when it can be imported.
* An AppImage is an ELF program (its runtime) with "AI" and the format version at byte 8.
*/
internal fun problem(file: File): String? {
if (!file.isFile) return "The file is gone"
val head = ByteArray(20)
try {
RandomAccessFile(file, "r").use { if (it.read(head) < head.size) return "The file is too small to be an AppImage" }
} catch (e: Exception) {
return "The file cannot be read: ${e.message}"
}
if (head[0] != 0x7f.toByte() || head[1] != 'E'.code.toByte() || head[2] != 'L'.code.toByte() || head[3] != 'F'.code.toByte()) {
return "This is not an AppImage (it is not a Linux program)"
}
val machine = (head[18].toInt() and 0xff) or ((head[19].toInt() and 0xff) shl 8)
return when {
machine == ELF_X86_64 -> "This AppImage is built for x86_64 PCs. DroidDeck runs ARM64 (aarch64) AppImages - look for an \"aarch64\" or \"arm64\" download."
machine != ELF_AARCH64 -> "This AppImage is not built for ARM64 (aarch64)"
head[8] != 'A'.code.toByte() || head[9] != 'I'.code.toByte() -> "This is an ARM64 program but not an AppImage"
head[10].toInt() != 2 -> "Only type 2 AppImages can be imported (this one is type ${head[10].toInt()})"
else -> null
}
}
/** A directory name from the image's file name: "Some_App-1.2-aarch64.AppImage" -> "some-app-1-2-aarch64". */
internal fun idFor(fileName: String, taken: Set<String>): String {
val base = fileName.removeSuffix(".AppImage").removeSuffix(".appimage").lowercase()
.replace(Regex("[^a-z0-9]+"), "-").trim('-').take(48).ifEmpty { "appimage" }
var id = base
var n = 2
while (id in taken) id = "$base-${n++}"
return id
}
/** Imports [file]; null on success, else what went wrong. [onProgress] gets a stage line. */
fun import(context: Context, file: File, onProgress: (String) -> Unit): String? {
if (!LinuxRuntime.isInstalled(context)) return "Install the Linux runtime first"
problem(file)?.let { return it }
val base = root(context).apply { mkdirs() }
// Room for the image while it is extracted, and the extracted tree (about the same again).
if (StatFs(base.path).availableBytes < file.length() * 3) {
return "Not enough free space: importing needs about ${FileUtils.sizeToString(file.length() * 3)}"
}
val id = idFor(file.name, base.list()?.toSet() ?: emptySet())
val dir = File(base, id)
val image = File(dir, "image.AppImage")
try {
dir.mkdirs()
// Copied in, not run where it lies: shared storage is mounted noexec, and proot's loader
// maps a program executable.
onProgress("Copying ${file.name}")
file.inputStream().use { input -> image.outputStream().use { FileUtils.copy(input, it) } }
image.setExecutable(true, false)
onProgress("Extracting ${file.name}")
val guestDir = "$GUEST_DIR/$id"
val out = StringBuilder()
val status = GuestCommand.run(context, listOf(
"/bin/bash", "-c",
"cd \"$1\" && ./image.AppImage --appimage-extract >/dev/null && mv squashfs-root app",
"extract", guestDir,
), logName = "appimage-import") { line -> if (out.length < 2000) out.appendLine(line) }
if (status != 0 || !File(dir, "app").isDirectory) {
FileUtils.delete(dir)
return "The AppImage could not be extracted" + (out.lines().lastOrNull { it.isNotBlank() }?.let { ": $it" } ?: "")
}
image.delete()
describe(dir, file)
writeMenuEntry(context, dir)
return null
} catch (e: Exception) {
Log.e(TAG, "import ${file.name}", e)
FileUtils.delete(dir)
return e.message ?: "Import failed"
} finally {
image.delete()
}
}
/** Name, comment and icon from the image's own desktop entry; the file name when it has none. */
private fun describe(dir: File, source: File) {
val app = File(dir, "app")
val entry = app.listFiles { f -> f.isFile && f.name.endsWith(".desktop") }?.firstOrNull()
val text = entry?.let { FileUtils.readString(it) }
fun key(k: String) = text?.let { desktopKey(it, k) }
val name = key("Name") ?: source.name.substringBeforeLast('.')
FileUtils.writeString(File(dir, "name"), name)
key("Comment")?.let { FileUtils.writeString(File(dir, "comment"), it) }
key("Categories")?.let { FileUtils.writeString(File(dir, "categories"), it) }
// The icon the entry names, beside it or anywhere in the image's icon theme, else .DirIcon.
val iconName = key("Icon")
val candidates = listOfNotNull(
iconName?.let { File(app, "$it.png") },
iconName?.let { n -> app.walkTopDown().maxDepth(8).filter { it.isFile && it.name == "$n.png" }.maxByOrNull { it.length() } },
File(app, ".DirIcon"),
)
candidates.firstOrNull { it.isFile && isPng(it) }?.copyTo(File(dir, "icon.png"), overwrite = true)
}
private fun isPng(f: File): Boolean = try {
f.inputStream().use { val b = ByteArray(4); it.read(b) == 4 && b[1] == 'P'.code.toByte() && b[2] == 'N'.code.toByte() && b[3] == 'G'.code.toByte() }
} catch (e: Exception) { false }
/** A key of a desktop entry's main group, unlocalised. */
internal fun desktopKey(desktop: String, key: String): String? {
var inMain = false
for (line in desktop.lineSequence()) {
val t = line.trim()
if (t.startsWith("[")) { inMain = t == "[Desktop Entry]"; continue }
if (inMain && t.startsWith("$key=")) return t.substringAfter('=').trim().takeIf { it.isNotEmpty() }
}
return null
}
private fun menuEntry(context: Context, id: String) =
// A system directory, not ~/.local/share/applications: the desktop copies game entries from
// here into the user's with a GPU launcher (droiddeck-desktop-gpu) while it runs on pixman.
File(LinuxRuntime.rootDir(context), "usr/local/share/applications/droiddeck-appimage-$id.desktop")
/** The Linux desktop's menu entry: the desktop's own Exec rules (and droiddeck-gpu) apply to it. */
private fun writeMenuEntry(context: Context, dir: File) {
val id = dir.name
val name = FileUtils.readString(File(dir, "name"))?.trim() ?: id
val categories = FileUtils.readString(File(dir, "categories"))?.trim()?.takeIf { it.isNotEmpty() } ?: "Utility;"
val icon = if (File(dir, "icon.png").isFile) "$GUEST_DIR/$id/icon.png" else "application-x-executable"
FileUtils.writeString(menuEntry(context, id),
"[Desktop Entry]\nType=Application\nName=$name\nExec=$LAUNCHER $GUEST_DIR/$id %U\nIcon=$icon\n" +
"Terminal=false\nCategories=$categories\nX-DroidDeck-AppImage=$id\n")
}
fun remove(context: Context, id: String) {
if (id.isEmpty() || '/' in id || id.startsWith(".")) return
FileUtils.delete(File(root(context), id))
menuEntry(context, id).delete()
}
private object Files {
fun isLink(f: File) = java.nio.file.Files.isSymbolicLink(f.toPath())
}
}
@@ -0,0 +1,424 @@
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()) Files.createSymbolicLink(link.toPath(), java.nio.file.Paths.get(target))
} 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) } }
if (binds.any { it.guest == "/dev" }) gpu(rootfs, root, env)?.forEach { addBind(it) }
preload(session, rootfs, env).forEach { addBind(it) }
browserSandboxes(env)
return Plan(root, binds, request.optString("cwd", "/").ifEmpty { "/" }, argv, env)
}
/** Where a sandbox finds the runtime's own GPU driver. */
private const val GPU_DIR = "/run/droiddeck-gpu"
/**
* The GPU, for an app that is given /dev. Flathub's Mesa has Turnip, but built for the DRM
* kernel driver: Adreno on Android is KGSL, so apps fell back to drawing on the CPU. The
* runtime's own Turnip has the KGSL backend and needs nothing the Freedesktop runtime lacks
* but three libraries, which come with it; Mesa's GL then runs on it through Zink. An app's
* own choice of driver (VK_ICD_FILENAMES, MESA_LOADER_DRIVER_OVERRIDE) is left alone.
*/
private fun gpu(rootfs: String, root: File, env: MutableMap<String, String>): List<Bind>? {
val lib = File(rootfs, "usr/lib")
val driver = File(lib, "libvulkan_freedreno.so")
if (!driver.isFile || !File("/dev/kgsl-3d0").exists()) return null
if (env.containsKey("VK_ICD_FILENAMES") || env.containsKey("VK_DRIVER_FILES")) return null
// A window on the desktop while labwc composites with pixman gets shared-memory buffers
// only; Zink cannot present there, so such an app keeps Flathub's own Mesa (llvmpipe).
// From the desktop, games go through droiddeck-gpu's gamescope and do get the GPU.
if (env["WLR_RENDERER"] == "pixman" && env["GAMESCOPE_WAYLAND_DISPLAY"] == null) return null
val binds = ArrayList<Bind>()
for (name in listOf("libvulkan_freedreno.so", "libdisplay-info.so.3", "libSPIRV-Tools.so", "libSPIRV-Tools-opt.so")) {
val f = File(lib, name)
if (!f.exists()) { Log.w(TAG, "gpu: the runtime has no $name; apps draw on the CPU"); return null }
binds.add(Bind(f.canonicalPath, "$GPU_DIR/$name"))
}
// Beside the sandbox root, in the sandbox's own directory: removed with it.
val icd = File(root.parentFile, "freedreno_icd.json")
icd.writeText("{\"file_format_version\": \"1.0.0\", \"ICD\": {\"library_path\": \"$GPU_DIR/libvulkan_freedreno.so\", \"api_version\": \"1.4.0\"}}\n")
binds.add(Bind(icd.path, "$GPU_DIR/freedreno_icd.json"))
env["VK_DRIVER_FILES"] = "$GPU_DIR/freedreno_icd.json"
env["VK_ICD_FILENAMES"] = "$GPU_DIR/freedreno_icd.json"
if (!env.containsKey("MESA_LOADER_DRIVER_OVERRIDE") && !env.containsKey("GALLIUM_DRIVER")) {
env["MESA_LOADER_DRIVER_OVERRIDE"] = "zink"
env["GALLIUM_DRIVER"] = "zink"
}
// The driver's own libraries, found beside it; nothing else is in that directory.
env["LD_LIBRARY_PATH"] = listOf(env["LD_LIBRARY_PATH"], GPU_DIR).filter { !it.isNullOrEmpty() }.joinToString(":")
return binds
}
/**
* What every program in the session preloads (SessionFiles writes /etc/ld.so.preload): the
* session shim - SysV shared memory for X11, the network calls Android refuses - and the
* controller reader. A sandbox's /etc is the runtime's, so the same libraries go in through
* LD_PRELOAD instead, with the directory the controllers' rings live in; without them an app
* saw no gamepad. Both are built against an older glibc than any Flathub runtime has.
*/
private fun preload(session: List<Bind>, rootfs: String, env: MutableMap<String, String>): List<Bind> {
val libs = FileUtils.readString(File(rootfs, "etc/ld.so.preload"))?.lines()
?.map { it.trim() }?.filter { it.startsWith("/") && File(rootfs + it).isFile } ?: return emptyList()
if (libs.isEmpty()) return emptyList()
val binds = ArrayList<Bind>()
val paths = libs.map { lib -> "$PRELOAD_DIR/${lib.substringAfterLast('/')}".also { binds.add(Bind(rootfs + lib, it)) } }
env["LD_PRELOAD"] = (paths + listOfNotNull(env["LD_PRELOAD"]?.takeIf { it.isNotBlank() })).joinToString(":")
// Not through ZYPAK_LD_PRELOAD to Chromium's children: the session shim's stat hooks then
// hide the setuid bit zypak fakes on chrome-sandbox, and Chromium refuses to start.
// The controllers: FAKE_EVDEV_DIR is <session>/dev/input, the rings beside it in <session>/dev.
env["FAKE_EVDEV_DIR"]?.let { File(it).parent }?.let { dev ->
binds.addAll(bindsFor(session, rootfs, dev, dev))
}
return binds
}
private const val PRELOAD_DIR = "/run/droiddeck-preload"
/**
* Browsers sandbox their own children, and neither way works in here. Firefox wants
* namespaces and seccomp from the kernel; its content processes died without them (the
* desktop sets the same for the runtime's own Firefox). Chromium and Electron apps start
* their zygote through Flatpak's portal (zypak's spawn strategy), and the portal refuses:
* it knows a Flatpak by /proc/<pid>/root/.flatpak-info, and a proot sandbox's root is the
* host's. zypak's older strategy mimics the zygote inside the app's own sandbox instead.
*/
private fun browserSandboxes(env: MutableMap<String, String>) {
env.putIfAbsent("ZYPAK_ZYGOTE_STRATEGY_SPAWN", "0")
// Qt WebEngine is Chromium without zypak: it sets up its own namespace sandbox.
env.putIfAbsent("QTWEBENGINE_DISABLE_SANDBOX", "1")
// Chromium on Wayland asks the render node the compositor names for its DRM version;
// KGSL has none, and the GPU process aborts until Chromium gives up ("GPU process isn't
// usable"). Electron picks Wayland from XDG_SESSION_TYPE; with an X server there (every
// session has Xwayland) it stays on X11, which works. GTK and Qt go by their own settings.
if (env.containsKey("DISPLAY")) env["XDG_SESSION_TYPE"] = "x11"
for (name in listOf("MOZ_DISABLE_CONTENT_SANDBOX", "MOZ_DISABLE_GMP_SANDBOX", "MOZ_DISABLE_RDD_SANDBOX",
"MOZ_DISABLE_SOCKET_PROCESS_SANDBOX", "MOZ_DISABLE_UTILITY_SANDBOX")) {
env.putIfAbsent(name, "1")
}
}
/**
* /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")
// Sandboxes start side by side; one must never read another's half-written copy.
if (!out.isFile || out.readText() != text) {
val staged = File.createTempFile("cpuinfo", null, context.cacheDir)
staged.writeText(text)
if (!staged.renameTo(out)) staged.delete()
}
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)
// A sandbox lives exactly as long as its bwrap stand-in, which ends it; a session starting
// meanwhile (OrphanReaper) must not take it - an install's extra-data step, say.
com.droiddeck.launcher.session.OrphanReaper.keep(pid)
Log.i(TAG, "sandbox $id pid $pid: ${plan.argv.joinToString(" ")} (${plan.binds.size} binds)")
synchronized(out) { frame(out, 'P', pid.toString().toByteArray()) }
// The stand-in going away (Flatpak killed, the session over) ends the sandbox.
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()
com.droiddeck.launcher.session.OrphanReaper.release(pid)
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,204 @@
package com.droiddeck.launcher.runtime
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.core.FileUtils
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, its output also in Download/DroidDeck/flatpak-<verb>.log. */
private fun runGuest(context: Context, argv: List<String>, fakeRoot: Boolean, onLine: (String) -> Unit): Int {
val name = "flatpak-" + (argv.getOrNull(2)?.takeIf { argv.getOrNull(1) == HELPER } ?: "setup")
return GuestCommand.run(context, argv, fakeRoot, name, onLine)
}
private inline fun <T> exclusive(what: String, block: () -> T): T? {
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
// Progress lines carry no ref; they belong to the step the last "op" line started.
var step = "Starting"
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" -> { step = stage(o); onProgress(step, overall(o, 0)) }
"progress" -> onProgress(step, 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 = refLabel(ref)
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 }
}
/** A ref in words: "app/org.supertuxproject.SuperTux/aarch64/stable" is "SuperTux". */
internal fun refLabel(ref: String): String {
val parts = ref.split('/')
val id = parts.getOrNull(1) ?: return ref
if (parts.firstOrNull() == "app") return id.substringAfterLast('.')
val branch = parts.getOrNull(3)?.let { " $it" } ?: ""
return when {
id.endsWith(".Locale") -> "translations"
".GL." in id || ".GL32." in id -> "graphics drivers"
"codecs" in id.lowercase() || id.endsWith(".ffmpeg-full") -> "media codecs"
id.endsWith(".Platform") -> when (val vendor = id.removeSuffix(".Platform").substringAfterLast('.')) {
"gnome", "kde" -> vendor.uppercase()
else -> vendor.replaceFirstChar { it.uppercase() }
} + " runtime$branch"
else -> id.substringAfterLast('.')
}
}
/** 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
}
}
@@ -0,0 +1,69 @@
package com.droiddeck.launcher.runtime
import android.content.Context
import android.os.Environment
import com.droiddeck.launcher.core.HostProcess
import com.droiddeck.launcher.session.OrphanReaper
import com.droiddeck.launcher.session.SessionFiles
import com.droiddeck.launcher.session.SessionPrefs
import java.io.File
/**
* One command in the Linux runtime outside any session - the store's Flatpak work, an AppImage
* import - in a proot of its own. A session starting meanwhile leaves it running (OrphanReaper).
*/
object GuestCommand {
/**
* Runs [argv] and hands each output line to [onLine]; returns the exit status. [fakeRoot] is
* for the package tools, which refuse any uid but 0. With [logName], everything the command
* says also goes to Download/DroidDeck/<logName>.log beside the session logs, so a problem can
* be handed over like a session's.
*/
fun run(context: Context, argv: List<String>, fakeRoot: Boolean = false, logName: String? = null,
onLine: (String) -> Unit): Int {
val log = logName?.let {
try { File(LinuxRuntime.debugLogDir().apply { mkdirs() }, "$it.log").printWriter() } catch (e: Exception) { null }
}
log?.println("== ${java.util.Date()} ${argv.joinToString(" ")}")
try {
return runLogged(context, argv, fakeRoot) { line -> log?.println(line); log?.flush(); onLine(line) }
} finally {
log?.close()
}
}
private fun runLogged(context: Context, argv: List<String>, fakeRoot: Boolean, onLine: (String) -> Unit): Int {
val root = LinuxRuntime.rootDir(context)
LinuxRuntime.writeAccounts(context)
SessionFiles.stage(context, root)
// The resolver otherwise names the DNS of whatever network the last session was on.
LinuxNetworkLinkComponent(context, root).publish()
val runtimeDir = File(context.filesDir, ".flatpak-rt").apply { mkdirs() }
val cmd = LinuxRuntime.prootPrefix(context, root, "/root", fakeRoot)
LinuxRuntime.binds(context, null, runtimeDir, Environment.getExternalStorageDirectory(), null)
.forEach { cmd.add("-b"); cmd.add(it) }
cmd += listOf(
"/usr/bin/env", "-i", "HOME=/root", "USER=root", "LANG=C.UTF-8",
"PATH=/usr/local/bin:/usr/bin:/bin", "XDG_RUNTIME_DIR=${runtimeDir.path}",
"XDG_DATA_HOME=/root/.local/share", "FLATPAK_BWRAP=${FlatpakManager.BWRAP}",
"XDG_DATA_DIRS=/root/.local/share/flatpak/exports/share:/usr/local/share:/usr/share",
)
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)
}
}
}
@@ -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());
@@ -198,8 +241,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;
}
/**
@@ -328,7 +372,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,13 @@ 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 user's own AppImages, extracted at import (AppImageManager).
"usr/local/bin/bannerlator-appimage-run" to "usr/local/bin/bannerlator-appimage-run",
// The SteamOS helpers the client calls in Deck mode: the two Armada found it needs, plus
// the three under /usr/bin, all no-ops that answer "nothing to do" (see each file).
"usr/bin/steamos-update" to "usr/bin/steamos-update",
@@ -30,6 +30,20 @@ object SessionPrefs {
prefs(context).edit().putBoolean("launcherFullscreen", on).apply()
}
/** The Flathub Store (beta): its rail item and the Store's apps on the Desktop page. Off by default. */
fun storeEnabled(context: Context): Boolean = prefs(context).getBoolean("storeEnabled", false)
fun setStoreEnabled(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("storeEnabled", on).apply()
}
/** AppImage import (beta): the AppImages section on the Desktop page. Off by default. */
fun appImagesEnabled(context: Context): Boolean = prefs(context).getBoolean("appImagesEnabled", false)
fun setAppImagesEnabled(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("appImagesEnabled", on).apply()
}
fun hudEnabled(context: Context): Boolean = prefs(context).getBoolean("hud", true)
fun setHudEnabled(context: Context, on: Boolean) {
@@ -0,0 +1,63 @@
package com.droiddeck.launcher.store
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.widget.Toast
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.droiddeck.launcher.runtime.AppImageManager
import java.io.File
/** The imported AppImages and the import in progress, for the Desktop page. */
object AppImageState {
private val main = Handler(Looper.getMainLooper())
var items by mutableStateOf<List<AppImageManager.Item>>(emptyList())
private set
/** The file being imported and what the import is doing; null when idle. */
var importing by mutableStateOf<String?>(null)
private set
var stage by mutableStateOf<String?>(null)
private set
/** Why the last import was refused, shown on the page until the next one. */
var lastError by mutableStateOf<String?>(null)
private set
fun refresh(context: Context) {
val app = context.applicationContext
Thread({
val list = AppImageManager.list(app)
main.post { items = list }
}, "appimage-refresh").start()
}
fun import(context: Context, file: File) {
if (importing != null) return
val app = context.applicationContext
importing = file.name; stage = "Starting…"; lastError = null
Thread({
val problem = try {
AppImageManager.import(app, file) { s -> main.post { stage = s } }
} catch (e: Exception) {
e.message ?: e.toString()
}
val list = AppImageManager.list(app)
main.post {
importing = null; stage = null; items = list
lastError = problem?.let { "${file.name}: $it" }
if (problem == null) Toast.makeText(app, "${file.name} imported", Toast.LENGTH_SHORT).show()
}
}, "appimage-import").start()
}
fun remove(context: Context, id: String) {
val app = context.applicationContext
Thread({
AppImageManager.remove(app, id)
val list = AppImageManager.list(app)
main.post { items = list }
}, "appimage-remove").start()
}
}
@@ -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,142 @@
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
/** Flathub has been asked this session, so an empty [updates] means up to date. */
var updatesChecked 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; updatesChecked = true } 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" && s.storeEnabled) {
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)
@@ -192,6 +197,8 @@ private fun Content(
Rise(3) { SectionTitle("Emulators", "${installed.size} installed") }
Rise(4) { EmulatorGrid(installed, first = true, onSelect = onSelect) }
}
if (s.storeEnabled) Rise(5) { InstalledAppsGrid(a) }
if (s.appImagesEnabled) Rise(5) { AppImagesSection(a, s.ready) }
if (available.isNotEmpty()) {
Rise(5) { SectionTitle("Available to install", available.size.toString()) }
Rise(6) { EmulatorGrid(available, first = installed.isEmpty(), onSelect = onSelect) }
@@ -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") }
if (s.storeEnabled) 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") }
@@ -114,6 +114,9 @@ class FrontEndState(
val phantomProcessStatus: PhantomProcessStatus = PhantomProcessStatus.NOT_APPLICABLE,
val showPhantomGate: Boolean = false,
val launcherFullscreen: Boolean = true,
/** Beta features the user turns on in Setup: the Flathub Store and AppImage import. */
val storeEnabled: Boolean = false,
val appImagesEnabled: Boolean = false,
)
class FrontEndActions(
@@ -122,6 +125,11 @@ 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 = { _, _ -> },
/** Pick an AppImage to import; open an imported one by its directory and name. */
val onImportAppImage: () -> Unit = {},
val onAppImage: (String, String) -> Unit = { _, _ -> },
val onRom: (Library.Rom) -> Unit,
val onResume: () -> Unit,
val onSteamSettings: () -> Unit,
@@ -147,6 +155,8 @@ class FrontEndActions(
val onPageBack: () -> Unit = {},
val onTheme: (String) -> Unit = {},
val onLauncherFullscreen: (Boolean) -> Unit = {},
val onStoreEnabled: (Boolean) -> Unit = {},
val onAppImagesEnabled: (Boolean) -> Unit = {},
val onHomeApp: () -> Unit = {},
val onHomeScreen: (Boolean) -> Unit = {},
val onAndroidApp: (HomeApp.LaunchableApp, Int?) -> Unit = { _, _ -> },
@@ -353,6 +363,8 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
selected = if (selected.startsWith("emu:")) "desktop" else "emu:" + selected.removePrefix("rom:").substringBefore(':')
}
LaunchedEffect(s.isHomeApp) { if (!s.isHomeApp && selected == "android-apps") selected = "steam" }
// The Store turned off in Setup takes its page with it.
LaunchedEffect(s.storeEnabled) { if (!s.storeEnabled && selected == "store") selected = "steam" }
// The last game uninstalled leaves the Games tab on its empty state.
LaunchedEffect(s.steamGames.isEmpty()) {
if (s.steamGames.isEmpty() && selected.startsWith("app:")) selected = "games"
@@ -312,6 +312,20 @@ internal fun SetupPanel(
ActionRow("Default Home app", s.defaultHomeLabel ?: "Choose a Home app", "Choose", a.onHomeApp)
}
}
SettingsGroup("Linux apps (beta)") {
ToggleRow(
host, "store-enabled", "Flathub Store",
if (s.storeEnabled) "The Store is in the menu. Some apps may not start; logs are in Download/DroidDeck"
else "Off: install Linux apps and games from Flathub with Flatpak",
s.storeEnabled,
) { a.onStoreEnabled(it) }
ToggleRow(
host, "appimages-enabled", "AppImages",
if (s.appImagesEnabled) "Add AppImage is on the Desktop page. ARM64 (aarch64) AppImages only"
else "Off: import ARM64 AppImages from your storage",
s.appImagesEnabled,
) { a.onAppImagesEnabled(it) }
}
}
else -> {
SettingsGroup("About") {
@@ -0,0 +1,556 @@
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() }
// A pad's focus sits on something the page is about to replace - the tile that opens an app,
// a tab's contents - and would be lost with it. Each move says where focus goes next.
val ff = LocalFrontFocus.current
val inputMode = androidx.compose.ui.platform.LocalInputModeManager.current
val tabFocus = remember { List(TABS.size) { androidx.compose.ui.focus.FocusRequester() } }
var focusMove by remember { mutableStateOf<Pair<String, Int>?>(null) }
var lastApp by remember { mutableStateOf<String?>(null) }
fun closeApp() { lastApp = openApp; openApp = null; focusMove = "tile" to (focusMove?.second ?: 0) + 1 }
fun switchTab(to: Int) { tab = to; focusMove = "tab" to (focusMove?.second ?: 0) + 1 }
BackHandler(enabled = openApp != null) { closeApp() }
val scroll = rememberScrollState()
LaunchedEffect(openApp, tab) { scroll.scrollTo(0) }
LaunchedEffect(focusMove, openApp) {
if (inputMode.inputMode != androidx.compose.ui.input.InputMode.Keyboard) return@LaunchedEffect
val target = when {
openApp != null -> ff?.primary
focusMove?.first == "tile" -> lastApp?.let { ff?.items?.get("tile:store:$it") } ?: tabFocus[tab]
focusMove?.first == "tab" -> tabFocus[tab]
else -> null
} ?: return@LaunchedEffect
var landed = false
focusWithinFrames({ landed }) { target.also { landed = runCatching { it.requestFocus() }.isSuccess } }
}
Column(
modifier = modifier.verticalScroll(scroll).padding(horizontal = padH, vertical = padV)
.bumpers(onPrevious = { if (openApp == null) switchTab((tab + TABS.size - 1) % TABS.size) },
onNext = { if (openApp == null) switchTab((tab + 1) % TABS.size) }),
) {
val id = openApp
if (id != null) {
AppDetail(s, a, id) { closeApp() }
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, { switchTab(it) }, modifier = Modifier.padding(top = 14.dp, bottom = 4.dp), focusRequesters = tabFocus,
)
}
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 view = androidx.compose.ui.platform.LocalView.current
val run = {
// The field is an Android EditText; Done leaves its keyboard over the results otherwise.
(view.context.getSystemService(android.content.Context.INPUT_METHOD_SERVICE) as? android.view.inputmethod.InputMethodManager)
?.hideSoftInputFromWindow(view.windowToken, 0)
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")
} else if (StoreState.updatesChecked && !StoreState.checkingUpdates) ActionChip("● Everything is up to date", ok = true)
}
}
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
// Remove asks twice: one stray press of A should not cost a download. The second press has
// to come within a few seconds; the app's own data in ~/.var/app stays either way.
var confirmRemove by remember(id) { mutableStateOf(false) }
LaunchedEffect(confirmRemove) { if (confirmRemove) { kotlinx.coroutines.delay(4000); confirmRemove = false } }
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 if (confirmRemove) "Press again to remove" else "Remove", enabled = StoreState.busy == null) {
if (confirmRemove) { confirmRemove = false; StoreState.uninstall(ctx, id, name) } else confirmRemove = true
}
} 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),
)
}
/** The apps installed from the Store, as launch tiles - on the Desktop page beside the emulators. */
@Composable
internal fun InstalledAppsGrid(a: FrontEndActions) {
val ctx = LocalContext.current
LaunchedEffect(Unit) { StoreState.refresh(ctx) }
val apps = StoreState.installed
if (apps.isEmpty()) return
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val columns = if (LocalNarrowPane.current) 1 else 3
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
SectionTitle("Apps from the Store", apps.size.toString())
apps.chunked(columns).forEach { row ->
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)) {
row.forEach { app ->
key(app.id) {
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.weight(1f).fillMaxHeight().paneItem("tile:flatpak:${app.id}")
.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) { a.onFlatpakApp(app.id, app.name) }
.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(app.summary ?: "Flatpak", fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
}
repeat(columns - row.size) { Spacer(Modifier.weight(1f)) }
}
}
}
}
/**
* The user's own AppImages on the Desktop page: an Add button, each one to open, and Remove
* (pressed twice, like the store's). The picker, the check and the extraction live elsewhere
* (MainActivity, AppImageManager); this only shows them.
*/
@Composable
internal fun AppImagesSection(a: FrontEndActions, runtimeReady: Boolean) {
val ctx = LocalContext.current
LaunchedEffect(Unit) { com.droiddeck.launcher.store.AppImageState.refresh(ctx) }
val state = com.droiddeck.launcher.store.AppImageState
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
SectionTitle("AppImages", state.items.size.takeIf { it > 0 }?.toString())
Actions {
SecondaryButton(if (state.importing != null) "Importing…" else "Add AppImage", enabled = runtimeReady && state.importing == null) { a.onImportAppImage() }
if (!runtimeReady) ActionChip("Runtime required", ok = false)
}
state.importing?.let { name ->
Text("$name · ${state.stage ?: "Starting…"}", fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
LinearProgressIndicator(modifier = Modifier.fillMaxWidth().height(4.dp))
}
state.lastError?.let { Note(it) }
if (state.items.isEmpty() && state.importing == null && state.lastError == null) {
Note("Add an ARM64 (aarch64) AppImage from your storage. It is unpacked into the Linux runtime once, then opens full screen from here or from the desktop's menu.")
}
state.items.forEach { item ->
key(item.id) {
var confirm by remember(item.id) { mutableStateOf(false) }
LaunchedEffect(confirm) { if (confirm) { kotlinx.coroutines.delay(4000); confirm = false } }
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14).padding(end = 12.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.weight(1f).paneItem("tile:appimage:${item.id}").clip(Shape14)
.background(if (hot) pal.signal.copy(alpha = 0.10f) else Color.Transparent)
.border(2.dp, if (hot) pal.signal else Color.Transparent, Shape14)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button) { a.onAppImage(item.guestDir, item.name) }
.padding(horizontal = 12.dp, vertical = 10.dp),
) {
AppIcon(item.icon, item.name, 44)
Column(modifier = Modifier.weight(1f)) {
Text(item.name, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(item.comment ?: "AppImage", fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
SecondaryButton(if (confirm) "Press again to remove" else "Remove", compact = true) {
if (confirm) { confirm = false; com.droiddeck.launcher.store.AppImageState.remove(ctx, item.id) } else confirm = true
}
}
}
}
}
}
@@ -344,7 +344,7 @@ private fun WirelessStepForm(
}
@Composable
private fun AdbTextField(
internal fun AdbTextField(
value: String,
onValueChange: (String) -> Unit,
label: String,
@@ -0,0 +1,51 @@
package com.droiddeck.launcher.runtime
import java.io.File
import java.nio.file.Files
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class AppImageManagerTest {
private lateinit var dir: File
@Before fun setUp() { dir = Files.createTempDirectory("appimage").toFile() }
@After fun tearDown() { dir.deleteRecursively() }
/** An ELF header with the AppImage magic at byte 8 and [machine] at byte 18. */
private fun image(machine: Int, type: Int = 2, magic: Boolean = true): File {
val b = ByteArray(64)
b[0] = 0x7f; b[1] = 'E'.code.toByte(); b[2] = 'L'.code.toByte(); b[3] = 'F'.code.toByte()
if (magic) { b[8] = 'A'.code.toByte(); b[9] = 'I'.code.toByte(); b[10] = type.toByte() }
b[18] = (machine and 0xff).toByte(); b[19] = (machine shr 8).toByte()
return File(dir, "x.AppImage").apply { writeBytes(b) }
}
@Test fun acceptsAnArm64TypeTwoImage() = assertNull(AppImageManager.problem(image(183)))
@Test fun refusesX86WithAHint() = assertTrue(AppImageManager.problem(image(62))!!.contains("x86_64"))
@Test fun refusesTypeOne() = assertTrue(AppImageManager.problem(image(183, type = 1))!!.contains("type 2"))
@Test fun refusesAPlainProgram() = assertTrue(AppImageManager.problem(image(183, magic = false))!!.contains("not an AppImage"))
@Test fun refusesSomethingElse() {
val f = File(dir, "notes.AppImage").apply { writeText("hello, this is not a program at all") }
assertTrue(AppImageManager.problem(f)!!.contains("not an AppImage"))
}
@Test fun idsAreTidyAndUnique() {
assertEquals("some-app-1-2-aarch64", AppImageManager.idFor("Some_App-1.2-aarch64.AppImage", emptySet()))
assertEquals("some-app-2", AppImageManager.idFor("Some App.AppImage", setOf("some-app")))
}
@Test fun readsTheMainDesktopGroup() {
val entry = "[Desktop Entry]\nName=Duck\nName[de]=Ente\nIcon=duck\n[Desktop Action x]\nName=Other\n"
assertEquals("Duck", AppImageManager.desktopKey(entry, "Name"))
assertEquals("duck", AppImageManager.desktopKey(entry, "Icon"))
assertNull(AppImageManager.desktopKey(entry, "Comment"))
}
}
@@ -0,0 +1,127 @@
package com.droiddeck.launcher.runtime
import android.util.Base64
import org.robolectric.RuntimeEnvironment
import com.droiddeck.launcher.runtime.BwrapSpawner.Bind
import java.io.File
import java.nio.file.Files
import org.json.JSONArray
import org.json.JSONObject
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class BwrapSpawnerTest {
private val session = listOf(
Bind("/dev", "/dev"),
Bind("/cache/shm", "/dev/shm"),
Bind("/proc", "/proc"),
Bind("/rootfs/etc/bannerlator/proc/stat", "/proc/stat"),
Bind("/data/files", "/data/files"),
)
@Test fun underRespectsPathBoundaries() {
assertTrue(BwrapSpawner.under("/usr/lib", "/usr"))
assertTrue(BwrapSpawner.under("/usr", "/usr"))
assertFalse(BwrapSpawner.under("/usrlocal", "/usr"))
assertTrue(BwrapSpawner.under("/anything", "/"))
}
@Test fun hostPathGoesThroughTheMostSpecificSessionBind() {
assertEquals("/cache/shm/x", BwrapSpawner.hostPath(session, "/rootfs", "/dev/shm/x"))
assertEquals("/dev/null", BwrapSpawner.hostPath(session, "/rootfs", "/dev/null"))
assertEquals("/data/files/.wayland-rt/wayland-1", BwrapSpawner.hostPath(session, "/rootfs", "/data/files/.wayland-rt/wayland-1"))
}
@Test fun hostPathFallsBackToTheRootfs() {
assertEquals("/rootfs/root/.local/share/flatpak", BwrapSpawner.hostPath(session, "/rootfs/", "/root/.local/share/flatpak"))
}
@Test fun bindingDevBringsTheSessionsStandInsAlong() {
val binds = BwrapSpawner.bindsFor(session, "/rootfs", "/dev", "/dev")
assertEquals(Bind("/dev", "/dev"), binds.first())
assertTrue(Bind("/cache/shm", "/dev/shm") in binds)
assertFalse(binds.any { it.guest.startsWith("/proc") })
}
@Test fun nestedBindsFollowARenamedDestination() {
val binds = BwrapSpawner.bindsFor(session, "/rootfs", "/proc", "/newproc")
assertTrue(Bind("/rootfs/etc/bannerlator/proc/stat", "/newproc/stat") in binds)
}
@Test fun normalizeResolvesDotsLexically() {
assertEquals("/usr/lib/x", BwrapSpawner.normalize("/usr/lib/GL/../x"))
assertEquals("/", BwrapSpawner.normalize("/../.."))
}
@Test fun bindSpecsRoundTrip() {
assertEquals(Bind("/a", "/b"), Bind.parse("/a:/b"))
assertEquals(Bind("/a", "/a"), Bind.parse("/a"))
assertEquals("/a", Bind("/a", "/a").spec)
assertEquals("/a:/b", Bind("/a", "/b").spec)
}
}
/** The sandbox a Flatpak request describes, built on disk under a temporary directory. */
@RunWith(RobolectricTestRunner::class)
class BwrapSpawnerPlanTest {
private lateinit var dir: File
private lateinit var runtime: File
@Before fun setUp() {
dir = Files.createTempDirectory("bwrap-plan").toFile()
// Flatpak names the runtime by its path inside the rootfs.
runtime = File(LinuxRuntime.rootDir(RuntimeEnvironment.getApplication()), "runtime-files")
File(runtime, "lib").mkdirs()
}
@After fun tearDown() {
dir.deleteRecursively()
runtime.deleteRecursively()
}
private fun op(vararg pairs: Pair<String, Any>) = JSONObject().apply { pairs.forEach { (k, v) -> put(k, v) } }
private fun request(vararg ops: JSONObject) = JSONObject()
.put("argv", JSONArray().put("/app/bin/game"))
.put("env", JSONObject().put("FLATPAK_ID", "org.example.Game"))
.put("cwd", "/app")
.put("ops", JSONArray().apply { ops.forEach { put(it) } })
@Test fun buildsTheTreeFlatpakAsksFor() {
val context = RuntimeEnvironment.getApplication()
val info = Base64.encodeToString("[Application]\nname=org.example.Game\n".toByteArray(), Base64.NO_WRAP)
val plan = BwrapSpawner.plan(context, request(
op("t" to "bind", "src" to "/runtime-files", "dest" to "/usr", "ro" to true),
op("t" to "symlink", "target" to "usr/lib", "dest" to "/lib"),
op("t" to "dir", "dest" to "/usr/lib/GL", "empty" to true),
op("t" to "symlink", "target" to "default/lib/libEGL.so", "dest" to "/usr/lib/GL/libEGL.so"),
op("t" to "dir", "dest" to "/tmp"),
op("t" to "file", "dest" to "/.flatpak-info", "data" to info, "perms" to 0x180),
op("t" to "bind", "src" to "/nonexistent-${System.nanoTime()}", "dest" to "/run/x", "try" to true),
), dir)
// The runtime is bound at /usr, not written into.
assertTrue(plan.binds.any { it.guest == "/usr" && File(it.host).canonicalPath == runtime.canonicalPath })
assertFalse(File(runtime, "lib/GL").exists())
// The tmpfs under /usr is a directory of ours, and the link Flatpak makes inside it lands there.
val gl = plan.binds.single { it.guest == "/usr/lib/GL" }
assertTrue(gl.host.startsWith(dir.path))
assertEquals("default/lib/libEGL.so", Files.readSymbolicLink(File(gl.host, "libEGL.so").toPath()).toString())
// Top-level links, directories and files are made in the sandbox root itself.
assertEquals("usr/lib", Files.readSymbolicLink(File(plan.root, "lib").toPath()).toString())
assertTrue(File(plan.root, "tmp").isDirectory)
assertEquals("[Application]\nname=org.example.Game\n", File(plan.root, ".flatpak-info").readText())
// A --bind-try whose source is missing is left out.
assertFalse(plan.binds.any { it.guest == "/run/x" })
assertEquals(listOf("/app/bin/game"), plan.argv)
assertEquals("/app", plan.cwd)
assertEquals("org.example.Game", plan.env["FLATPAK_ID"])
}
}
@@ -0,0 +1,20 @@
package com.droiddeck.launcher.store
import com.droiddeck.launcher.runtime.FlatpakManager
import org.junit.Assert.assertEquals
import org.junit.Test
class FlathubApiTest {
@Test fun appstreamDescriptionsBecomeLines() {
val html = "<p>A game &amp; more.</p><ul><li>Fast</li><li>Free</li></ul><p>Enjoy</p>"
assertEquals("A game & more.\n• Fast\n• Free\n\nEnjoy", FlathubApi.plainText(html))
}
@Test fun refsReadAsWords() {
assertEquals("SuperTux", FlatpakManager.refLabel("app/org.supertuxproject.SuperTux/aarch64/stable"))
assertEquals("GNOME runtime 50", FlatpakManager.refLabel("runtime/org.gnome.Platform/aarch64/50"))
assertEquals("Freedesktop runtime 26.08", FlatpakManager.refLabel("runtime/org.freedesktop.Platform/aarch64/26.08"))
assertEquals("graphics drivers", FlatpakManager.refLabel("runtime/org.freedesktop.Platform.GL.default/aarch64/26.08"))
assertEquals("translations", FlatpakManager.refLabel("runtime/org.gnome.Platform.Locale/aarch64/50"))
}
}
+118
View File
@@ -0,0 +1,118 @@
# Flatpak and the Store
Both features are beta and off by default. Setup → Launcher → "Linux apps (beta)" has one toggle for the
Flathub Store, which shows the Store rail item and the Store's apps on the Desktop page, and one for
AppImages, which shows the AppImages section. Turning either off hides it; installed apps stay installed
and remain in the Linux desktop's menu.
The Store rail section installs apps from Flathub into the Linux runtime with Flatpak. Only
ARM64 builds are listed. Everything lives in one per-user installation at
`/root/.local/share/flatpak`, so nothing needs Flatpak's system helper or polkit.
## Putting Flatpak in the runtime
`bannerlator-flatpak-setup` runs under proot's fake root (`-0`), because pacman and pacman-key
refuse any other uid. The runtime's pacman database lists only the base image. The desktop
and emulator packages were unpacked over it without registering. A plain `pacman -S flatpak`
would therefore reinstall about a hundred packages, Mesa among them, over the runtime's KGSL
Turnip build, and run every hook in the image, mkinitcpio included. So pacman, with a
database of its own under `/var/cache/droiddeck-flatpak`, only downloads the nine packages
Flatpak adds to this image and checks their signatures against the Arch Linux ARM keyring.
The script then unpacks them without hooks and checks what the binaries link. Libraries that
the Desktop package normally brings (PyGObject, json-glib, fuse3 and others) are fetched too,
but only when their files are missing. Flathub is added as a per-user remote from a copy of
its `.flatpakrepo` carried in the script, so that step needs no network. Every store command
writes its output to `Download/DroidDeck/flatpak-<verb>.log`, next to the session logs. The
setup script also puts Flatpak's own error text in the failure message the app shows.
## bubblewrap without namespaces
Android gives apps no user namespaces, so `bwrap` cannot work. Flatpak is pointed at
`bannerlator-bwrap` instead (`FLATPAK_BWRAP`, exported by `bannerlator-session` and by the
store's commands). It reads bwrap's command line, including `--args` fds and the `--file` and
`--bind-data` payloads, and then does one of two things:
- A sandbox that remaps nothing runs directly. Flatpak's install triggers (`--ro-bind / /`) and
the D-Bus proxy (every top-level directory bound onto itself) are the cases, and they keep the
file descriptors Flatpak handed them.
- Anything else is described to the app over the abstract socket
`com.droiddeck.launcher.bwrap`, which serves only the app's own uid. `BwrapSpawner` builds the
tree under `cache/bwrap/<n>`: a root directory for top-level links, directories and files,
scratch directories for `--tmpfs` inside a bind, and every `--bind` translated from guest
to host paths through the session's own binds. It then starts it as a proot of its own
beside the session's. Output, the pid (for `--info-fd`) and the exit status flow back, and
the stand-in exiting kills the sandbox.
A proot nested in the session's proot would also work, but every system call then goes
through two tracers, about thirty times slower at a program's start.
The spawner also adds what the rootfs gives its own programs:
- **GPU.** Flathub's Mesa has Turnip only for DRM, and Adreno on Android is KGSL. The runtime's
own `libvulkan_freedreno.so` is bound in with the three libraries the Freedesktop runtime
lacks (`libdisplay-info`, SPIRV-Tools). Vulkan uses it, and Mesa's GL runs on it through Zink
(`MESA_LOADER_DRIVER_OVERRIDE=zink`). This is skipped for an app drawing into a desktop
composited by pixman, where Zink cannot present.
- **Controllers.** The libraries in the rootfs's `/etc/ld.so.preload` (the session shim and the
fake evdev reader) go in through `LD_PRELOAD`, with the session's `dev` directory.
- **Browsers.** Firefox's child sandboxes are switched off (`MOZ_DISABLE_*_SANDBOX`), and
Chromium and Electron apps use zypak's mimic strategy (`ZYPAK_ZYGOTE_STRATEGY_SPAWN=0`). The
Flatpak portal's Spawn refuses a caller whose `/proc/<pid>/root` holds no `.flatpak-info`,
and a proot sandbox's root is the host's.
- **CPU.** `/proc/cpuinfo` without the cores' part numbers. Snapdragon's ARMv9 cores imply SVE2
to LLVM, Qualcomm leaves SVE off, and llvmpipe's first shader died with SIGILL.
## Running apps
The front end starts an app as a run-mode session of `bannerlator-flatpak-run <app-id>`, full
screen under gamescope. Under gamescope it passes `--nosocket=wayland --socket=x11` and sets
`XDG_SESSION_TYPE=x11`. Otherwise Flatpak finds the app compositor's `wayland-0` and the window
opens behind gamescope. gamescope's own Wayland socket is no alternative: Chromium on Wayland
asks the render node it names for a DRM version, which KGSL cannot give, and aborts. The
launcher also starts a session bus when there is none. On the Linux desktop, the apps' exported
menu entries appear in the LXQt menu. While the desktop is composited by pixman, games are
wrapped through `droiddeck-gpu` like the rootfs's own.
## The store
`FlathubApi` reads flathub.org's public API: collections, search filtered to `aarch64`, and
AppStream details. `bannerlator-flatpak` drives libflatpak through PyGObject and prints one
JSON object per line (`op`, `progress`, `error`, `done`). `FlatpakManager` turns those lines
into the store's progress bar. Store commands run in a proot of their own, and
`OrphanReaper` spares them when a session starts.
## AppImages
"Add AppImage" on the Desktop page imports an ARM64, type 2 AppImage from storage. `AppImageManager`
checks its ELF header first, so an x86_64 image is refused with a message saying so. It then copies the
image in, because shared storage is noexec, and extracts it once to `/opt/appimages/user/<id>/app`.
proot has no FUSE, and extracting at every start would cost the whole image each time. The name,
comment and icon come from the image's own desktop entry. A menu entry goes to
`/usr/local/share/applications`, where the desktop's GPU wrapper picks it up.
`bannerlator-appimage-run` starts it:
- `APPIMAGE` stays unset, so apps don't offer to add themselves to the menu or update in place.
- Firefox forks get the same sandbox switches as Flatpaks.
- Electron apps (a `chrome-sandbox` beside them) get `--no-sandbox` and X11.
- A session bus is started when the session has none.
- The launcher waits while anything from the image still runs, because Electron apps relaunch themselves.
Known to fail:
- Obsidian's own AppImage traps (SIGTRAP) after startup. Its Flatpak works.
- Zen's AppImage hangs in Firefox's startup GPU probe. The Firefox Flatpak works.
## x86_64 apps
Flathub lists x86_64-only apps (Heroic, Discord, Spotify, Steam), and the store hides them. Running them
would need an x86 emulator in the sandbox. There's no FEX or box64 package in Arch Linux ARM; Valve's
ARM64 Steam can install its FEX compatibility tool, but only through the Steam client. With a FEX binary
available, the path would be:
1. `flatpak install --arch=x86_64`. Flatpak accepts it, and the x86 runtime is also the x86 root FEX needs.
2. `BwrapSpawner` starts those sandboxes with `proot -q`, through a small wrapper that runs FEXInterpreter.
`-q` passes QEMU-style arguments.
3. GPU drivers would need FEX's thunks, or Mesa runs emulated on the CPU.
x86 AppImages would also need an x86 rootfs for the libraries they don't bundle.
+18 -5
View File
@@ -12,6 +12,10 @@ import re
HOME = os.environ.get("HOME", "/root")
SYSTEM_DIR = "/usr/share/applications"
# The user's own AppImages (the front end's Add AppImage).
LOCAL_DIR = "/usr/local/share/applications"
# Flathub's games, as Flatpak exports them; the copies here win over these as they do over the system's.
FLATPAK_DIR = os.path.join(HOME, ".local/share/flatpak/exports/share/applications")
USER_DIR = os.path.join(HOME, ".local/share/applications")
MARK = "X-DroidDeck-GPU=1"
# The client and its games already hand themselves to the app (bannerlator-steam-launch).
@@ -37,6 +41,14 @@ def main_group(text):
return lines[:start + 1], lines[start + 1:end], lines[end:]
def gpu_exec(exec_line):
"""The Exec that runs a program on the GPU; a Flatpak app goes through its launcher, which
keeps it on gamescope's X server instead of the desktop's Wayland socket."""
if exec_line.startswith("/usr/bin/flatpak run ") or exec_line.startswith("flatpak run "):
exec_line = "/usr/local/bin/bannerlator-flatpak-run " + exec_line.split(" run ", 1)[1]
return "droiddeck-gpu " + exec_line
def wrap(text):
head, group, rest = main_group(text)
if group is None:
@@ -59,7 +71,7 @@ def wrap(text):
for l in group:
k = l.split("=", 1)[0].strip() if "=" in l else None
if k == "Exec":
out_group.append("Exec=droiddeck-gpu " + exec_line)
out_group.append("Exec=" + gpu_exec(exec_line))
elif k == "Actions":
continue
else:
@@ -73,7 +85,7 @@ def wrap(text):
wrapped_rest = []
for l in rest:
if l.startswith("Exec=") and not l[5:].startswith("droiddeck-gpu "):
l = "Exec=droiddeck-gpu " + l[5:]
l = "Exec=" + gpu_exec(l[5:])
wrapped_rest.append(l)
if wrapped_rest and wrapped_rest[0].strip():
wrapped_rest.insert(0, "")
@@ -92,8 +104,9 @@ def main():
on = enabled()
os.makedirs(USER_DIR, exist_ok=True)
written = set()
if on and os.path.isdir(SYSTEM_DIR):
for name in sorted(os.listdir(SYSTEM_DIR)):
sources = [d for d in (SYSTEM_DIR, LOCAL_DIR, FLATPAK_DIR) if os.path.isdir(d)] if on else []
for source in sources:
for name in sorted(os.listdir(source)):
if not name.endswith(".desktop") or SKIP.match(name):
continue
target = os.path.join(USER_DIR, name)
@@ -101,7 +114,7 @@ def main():
if os.path.exists(target) and not ours(target):
continue
try:
with open(os.path.join(SYSTEM_DIR, name), encoding="utf-8", errors="replace") as f:
with open(os.path.join(source, name), encoding="utf-8", errors="replace") as f:
text = wrap(f.read())
except OSError:
continue
@@ -0,0 +1,63 @@
#!/bin/bash
# An AppImage imported from the front end (AppImageManager), extracted once at import into
# /opt/appimages/user/<id>/app
# and started from there: proot has no FUSE to mount the image, and extracting it at every
# start (APPIMAGE_EXTRACT_AND_RUN) would cost the whole image in /tmp each time.
# bannerlator-appimage-run /opt/appimages/user/<id> [args...]
set -u
dir=${1:?usage: bannerlator-appimage-run <imported AppImage directory> [args...]}
shift
export APPDIR=$dir/app
# APPIMAGE is left unset on purpose: with it an app believes it runs from an image file and offers
# to add itself to the menu (DuckStation) or update the file in place - DroidDeck already does the
# one, and there is no image file for the other.
unset APPIMAGE
export ARGV0=$(cat "$dir/name" 2>/dev/null || basename "$dir")
if [ ! -x "$APPDIR/AppRun" ]; then
echo "bannerlator-appimage-run: $APPDIR/AppRun is missing; import the AppImage again" >&2
exit 127
fi
# Browsers sandbox their own children with namespaces and a setuid helper, neither of which proot
# can give them (the Flatpak sandboxes get the same; see BwrapSpawner): Firefox and its forks (Zen,
# LibreWolf) by these switches, Electron and Chromium builds by --no-sandbox. Chromium on Wayland
# also asks the render node for a DRM version KGSL cannot give, so with an X server up it stays on
# X11, which it picks from the session type.
export MOZ_DISABLE_CONTENT_SANDBOX=1 MOZ_DISABLE_GMP_SANDBOX=1 MOZ_DISABLE_RDD_SANDBOX=1
export MOZ_DISABLE_SOCKET_PROCESS_SANDBOX=1 MOZ_DISABLE_UTILITY_SANDBOX=1
flags=()
if [ -e "$APPDIR/chrome-sandbox" ] || compgen -G "$APPDIR/*/chrome-sandbox" >/dev/null; then
flags+=(--no-sandbox)
if [ -n "${DISPLAY:-}" ]; then
export XDG_SESSION_TYPE=x11
unset WAYLAND_DISPLAY
flags+=(--ozone-platform=x11)
fi
fi
echo "== appimage $ARGV0 ($APPDIR) ${flags[*]}"
cd "$APPDIR" || exit 1
# Apps expect a session bus; Electron's main process traps (SIGTRAP) on an address it cannot
# parse. A run mode has none, so one is started for the app, as for Flatpak apps.
case ${DBUS_SESSION_BUS_ADDRESS:-} in
unix:*) "$APPDIR/AppRun" "${flags[@]}" "$@" ;;
*) unset DBUS_SESSION_BUS_ADDRESS
if command -v dbus-run-session >/dev/null 2>&1; then
dbus-run-session -- "$APPDIR/AppRun" "${flags[@]}" "$@"
else
"$APPDIR/AppRun" "${flags[@]}" "$@"
fi ;;
esac
rc=$?
# The program a session was started for may hand over to another process and exit: Electron apps
# relaunch themselves (Obsidian on its first start), launchers fork their real program. The
# session ends with this script, and takes the rest with it - so it waits while anything running
# from the image is still up.
image_running() {
local p exe
for p in /proc/[0-9]*; do
exe=$(readlink "$p/exe" 2>/dev/null) || continue
case $exe in "$APPDIR"/*) return 0 ;; esac
done
return 1
}
while image_running; do sleep 2; done
exit $rc
@@ -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,237 @@
#!/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 prepare_rootfs():
"""Some runtimes (KDE 6.10) ship etc/resolv.conf as a link to /run/host/monitor/resolv.conf.
Deploying one, Flatpak removes that link only if GIO reports it existing, and GIO follows it:
dangling here, it was left in place and making Flatpak's own link failed ("File exists").
With the target present in the rootfs the link resolves and the deploy goes through."""
target = "/run/host/monitor/resolv.conf"
try:
if not os.path.exists(target):
os.makedirs(os.path.dirname(target), exist_ok=True)
try:
with open("/etc/resolv.conf", "rb") as src:
data = src.read()
except OSError:
data = b""
with open(target, "wb") as dst:
dst.write(data)
except OSError as err:
emit(e="warning", message="could not prepare %s: %s" % (target, err))
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.
# FLATPAK_TRANSACTION_ERROR_DETAILS_NON_FATAL is 1, but PyGObject does not always hand the
# flags over ("g_value_get_flags: assertion ... failed" and a 0). An extension installed
# alongside an app - its translations, debug data, a GL or codecs runtime - is what Flatpak
# marks non-fatal, as the flatpak CLI does: its failure costs that extension, not the app.
try:
non_fatal = bool(int(details) & 1)
except (TypeError, ValueError):
non_fatal = False
if not non_fatal:
try:
related = bool(op.get_related_to_ops())
except (AttributeError, TypeError):
related = False
ref_id = (op.get_ref() or "").split("/")[1:2]
ref_id = ref_id[0] if ref_id else ""
non_fatal = related or ref_id.endswith((".Locale", ".Debug")) or ".GL." in ref_id or "codecs" in ref_id
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, report_done=True):
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
if report_done:
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
# Flathub publishes apps on "stable"; libflatpak's default branch is "master".
branch, last = None, None
for candidate in ("stable", "master"):
try:
branch = inst.fetch_remote_ref_sync(REMOTE, Flatpak.RefKind.APP, app_id, arch, candidate, None).get_branch()
break
except GLib.Error as err:
last = err
if branch is None:
emit(e="error", message="%s is not on Flathub for %s (%s)" % (app_id, arch, last.message if last else "no branch"))
return 1
t, state = transaction(inst)
t.add_install(REMOTE, "app/%s/%s/%s" % (app_id, arch, branch), None)
rc = run(t, state, report_done=False)
# A failure the transaction did not report (an error handler that raised, say) must not read
# as an install: the app is there, or it is not.
if rc == 0 and app_ref(installation(), app_id) is None:
emit(e="error", message="%s did not install; see flatpak-install.log" % app_id)
return 1
if rc == 0:
emit(e="done")
return rc
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 in ("install", "update"):
prepare_rootfs()
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,28 @@
#!/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
# Under gamescope - a program's session (BL_INSIDE), or droiddeck-gpu from the desktop
# (GAMESCOPE_WAYLAND_DISPLAY) - windows have to reach gamescope's X server. Flatpak would hand an
# app wayland-0 (it finds it in the runtime directory even with WAYLAND_DISPLAY unset) and the
# window would open behind gamescope. gamescope's own Wayland socket is no way out: Chromium on
# Wayland asks the render node it names for its DRM version, which KGSL cannot answer, and exits.
# The session is X11 as far as an app can tell; Electron picks its platform from that.
# Its own flatpak options (--branch, --command, from a menu entry) come first, then the app id.
socket=()
if [ -n "${BL_INSIDE:-}" ] || [ -n "${GAMESCOPE_WAYLAND_DISPLAY:-}" ]; then
unset WAYLAND_DISPLAY
export XDG_SESSION_TYPE=x11
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,121 @@
#!/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"
# Libraries Flatpak and the store's helper also need, which the Desktop package brings but a
# runtime without it may lack: each is fetched only when its file is not already there.
while read -r pkg probe; do
[ -n "$pkg" ] || continue
compgen -G "$probe" >/dev/null || PACKAGES="$PACKAGES $pkg"
done <<'NEEDS'
json-glib /usr/lib/libjson-glib-1.0.so.0
fuse3 /usr/lib/libfuse3.so.4
fuse-common /etc/fuse.conf
libsodium /usr/lib/libsodium.so.*
libstemmer /usr/lib/libstemmer.so.*
polkit /usr/lib/libpolkit-gobject-1.so.0
mpdecimal /usr/lib/libmpdec.so.*
python /usr/bin/python3
libgirepository /usr/lib/libgirepository-2.0.so.0
gobject-introspection-runtime /usr/lib/girepository-1.0/GObject-2.0.typelib
python-gobject /usr/lib/python3*/site-packages/gi/__init__.py
NEEDS
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"
python3 -c 'import gi; gi.require_version("Flatpak", "1.0"); from gi.repository import Flatpak' 2>/dev/null \
|| fail "the store helper cannot load libflatpak from Python"
step "Adding Flathub"
export FLATPAK_BWRAP=/usr/local/bin/bannerlator-bwrap
# Flathub's descriptor - its URL and the key its commits are signed with - is carried here rather
# than fetched: adding the remote then needs no network, and a descriptor that did not arrive
# whole cannot be what failed. Flathub's own copy: https://dl.flathub.org/repo/flathub.flatpakrepo
cat > "$WORK/flathub.flatpakrepo" <<'REPO'
[Flatpak Repo]
Title=Flathub
Url=https://dl.flathub.org/repo/
Homepage=https://flathub.org/
Comment=Central repository of Flatpak applications
Description=Central repository of Flatpak applications
Icon=https://dl.flathub.org/repo/logo.svg
GPGKey=mQINBFlD2sABEADsiUZUOYBg1UdDaWkEdJYkTSZD68214m8Q1fbrP5AptaUfCl8KYKFMNoAJRBXn9FbE6q6VBzghHXj/rSnA8WPnkbaEWR7xltOqzB1yHpCQ1l8xSfH5N02DMUBSRtD/rOYsBKbaJcOgW0K21sX+BecMY/AI2yADvCJEjhVKrjR9yfRX+NQEhDcbXUFRGt9ZT+TI5yT4xcwbvvTu7aFUR/dH7+wjrQ7lzoGlZGFFrQXSs2WI0WaYHWDeCwymtohXryF8lcWQkhH8UhfNJVBJFgCY8Q6UHkZG0FxMu8xnIDBMjBmSZKwKQn0nwzwM2afskZEnmNPYDI8nuNsSZBZSAw+ThhkdCZHZZRwzmjzyRuLLVFpOj3XryXwZcSefNMPDkZAuWWzPYjxS80cm2hG1WfqrG0Gl8+iX69cbQchb7gbEb0RtqNskTo9DDmO0bNKNnMbzmIJ3/rTbSahKSwtewklqSP/01o0WKZiy+n/RAkUKOFBprjJtWOZkc8SPXV/rnoS2dWsJWQZhuPPtv3tefdDiEyp7ePrfgfKxuHpZES0IZRiFI4J/nAUP5bix+srcIxOVqAam68CbAlPvWTivRUMRVbKjJiGXIOJ78wAMjqPg3QIC0GQ0EPAWwAOzzpdgbnG7TCQetaVV8rSYCuirlPYN+bJIwBtkOC9SWLoPMVZTwQARAQABtC5GbGF0aHViIFJlcG8gU2lnbmluZyBLZXkgPGZsYXRodWJAZmxhdGh1Yi5vcmc+iQJUBBMBCAA+FiEEblwF2XnHba+TwIE1QYTdTZB6fK4FAllD2sACGwMFCRLMAwAFCwkIBwIGFQgJCgsCBBYCAwECHgECF4AACgkQQYTdTZB6fK5RJQ/+Ptd4sWxaiAW91FFk7+wmYOkEe1NY2UDNJjEEz34PNP/1RoxveHDt43kYJQ23OWaPJuZAbu+fWtjRYcMBzOsMCaFcRSHFiDIC9aTp4ux/mo+IEeyarYt/oyKb5t5lta6xaAqg7rwt65jW5/aQjnS4h7eFZ+dAKta7Y/fljNrOznUp81/SMcx4QA5G2Pw0hs4Xrxg59oONOTFGBgA6FF8WQghrpR7SnEe0FSEOVsAjwQ13Cfkfa7b70omXSWp7GWfUzgBKyoWxKTqzMN3RQHjjhPJcsQnrqH5enUu4Pcb2LcMFpzimHnUgb9ft72DP5wxfzHGAWOUiUXHbAekfq5iFks8cha/RST6wkxG3Rf44Zn09aOxh1btMcGL+5xb1G0BuCQnA0fP/kDYIPwh9z22EqwRQOspIcvGeLVkFeIfubxpcMdOfQqQnZtHMCabV5Q/Rk9K1ZGc8M2hlg8gHbXMFch2xJ0Wu72eXbA/UY5MskEeBgawTQnQOK/vNm7t0AJMpWK26Qg6178UmRghmeZDj9uNRc3EI1nSbgvmGlpDmCxaAGqaGL1zW4KPW5yN25/qeqXcgCvUjZLI9PNq3Kvizp1lUrbx7heRiSoazCucvHQ1VHUzcPVLUKKTkoTP8okThnRRRsBcZ1+jI4yMWIDLOCT7IW3FePr+3xyuy5eEo9a25Ag0EWUPa7AEQALT/CmSyZ8LWlRYQZKYw417p7Z2hxqd6TjwkwM3IQ1irumkWcTZBZIbBgrSOg6CcXD2oWydCQHWi9qaxhuhEl2bJL5LskmBcMxVdQeD0LLHd8QUnbnnIby8ocvWN1alPfvJFjCUTrmD22U1ycOzRw2lIe4kiQONbOZtdWrVImQQSndjFlisitbmlWHvHm2lOOYy8+GJB7YffVV193hmnBSJffCy4bvkuLxsI+n1DhOzc7MPV3z6HGk4HiEcF0yyt9tCYhpsxHFdBoq2h771HfAcS0s98EVAqYMFnf9em+4cnYpdI6mhIfS1FQiKl6DBAYA8tT3ggla00DurPo0JwX/zN+PaO5h/6O9aCZwV7G6rbkgMuqMergXaf8oP38gr0z+MqWnkfM63Bodq68GP4l4hd02BoFBbDf38TMuGQB14+twJMdfbAxo2MbgluvQgfwHfZ2ca6gyEY+9s/YD1gugLjV+S6CB51WkFNe1z4tAPgJZNxUcKCbeaHNbthl8Hks/pY9RCEseX/EdfzF18epbSjJMPh4DPQXbUoFwmyuYcoBOPmvZHNl9hK7B/1RP8w1ZrXk8qdupC0SNbafX7270B7lMMVImzZetGsM9ypXJ6llhp3FwW09iseNyGJGPsr/dvTMGDXqOPfU/9SAS1LSTY4K9PbRtdrBE318YX8mIk5ABEBAAGJBHIEGAEIACYWIQRuXAXZecdtr5PAgTVBhN1NkHp8rgUCWUPa7AIbAgUJEswDAAJACRBBhN1NkHp8rsF0IAQZAQgAHRYhBFSmzd2JGfsgQgDYrFYnAunj7X7oBQJZQ9rsAAoJEFYnAunj7X7oR6AP/0KYmiAFeqx14Z43/6s2gt3VhxlSd8bmcVV7oJFbMhdHBIeWBp2BvsUf00I0Zl14ZkwCKfLwbbORC2eIxvzJ+QWjGfPhDmS4XUSmhlXxWnYEveSek5Tde+fmu6lqKM8CHg5BNx4GWIX/vdLi1wWJZyhrUwwICAxkuhKxuP2Z1An48930eslTD2GGcjByc27+9cIZjHKa07I/aLffo04V+oMT9/tgzoquzgpVV4jwekADo2MJjhkkPveSNI420bgT+Q7Fi1l0X1aFUniBvQMsaBa27PngWm6xE2ZYvh7nWCdd5g0c0eLIHxWwzV1lZ4Ryx4ITO/VL25ItECcjhTRdYa64sA62MYSaB0x3eR+SihpgP3wSNPFu3MJo6FKTFdi4CBAEmpWHFW7FcRmd+cQXeFrHLN3iNVWryy0HK/CUEJmiZEmpNiXecl4vPIIuyF0zgSCztQtKoMr+injpmQGC/rF/ELBVZTUSLNB350S0Ztvw0FKWDAJSxFmoxt3xycqvvt47rxTrhi78nkk6jATKGyvP55sO+K7Q7Wh0DXA69hvPrYW2eu8jGCdVGxi6HX7L1qcfEd0378S71dZ3g9o6KKl1OsDWWQ6MJ6FGBZedl/ibRfs8p5+sbCX3lQSjEFy3rx6n0rUrXx8U2qb+RCLzJlmC5MNBOTDJwHPcX6gKsUcXZrEQALmRHoo3SrewO41RCr+5nUlqiqV3AohBMhnQbGzyHf2+drutIaoh7Rj80XRh2bkkuPLwlNPf+bTXwNVGse4bej7B3oV6Ae1N7lTNVF4Qh+1OowtGjmfJPWo0z1s6HFJVxoIof9z58Msvgao0zrKGqaMWaNQ6LUeC9g9Aj/9Uqjbo8X54aLiYs8Z1WNc06jKP+gv8AWLtv6CR+l2kLez1YMDucjm7v6iuCMVAmZdmxhg5I/X2+OM3vBsqPDdQpr2TPDLX3rCrSBiS0gOQ6DwN5N5QeTkxmY/7QO8bgLo/Wzu1iilH4vMKW6LBKCaRx5UEJxKpL4wkgITsYKneIt3NTHo5EOuaYk+y2+Dvt6EQFiuMsdbfUjs3seIHsghX/cbPJa4YUqZAL8C4OtVHaijwGo0ymt9MWvS9yNKMyT0JhN2/BdeOVWrHk7wXXJn/ZjpXilicXKPx4udCF76meE+6N2u/T+RYZ7fP1QMEtNZNmYDOfA6sViuPDfQSHLNbauJBo/n1sRYAsL5mcG22UDchJrlKvmK3EOADCQg+myrm8006LltubNB4wWNzHDJ0Ls2JGzQZCd/xGyVmUiidCBUrD537WdknOYE4FD7P0cHaM9brKJ/M8LkEH0zUlo73bY4XagbnCqve6PvQb5G2Z55qhWphd6f4B6DGed86zJEa/RhS
REPO
if ! flatpak remote-list --user --columns=name 2>/dev/null | grep -qx flathub; then
out=$(flatpak remote-add --user --if-not-exists flathub "$WORK/flathub.flatpakrepo" 2>&1)
rc=$?
echo "$out"
# What Flatpak said goes up with the failure, so the app can show it rather than a guess.
[ $rc -eq 0 ] || fail "could not add the Flathub remote: $(echo "$out" | grep -v '^[[:space:]]*$' | tail -1)"
fi
echo "== DONE $(flatpak --version)"
@@ -92,6 +92,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
+10
View File
@@ -45,6 +45,16 @@ Ported from WinNative (`main`, 53836ca9, "Fix/performance and vac"):
with no children or ptracees no longer walks every tracee; the per-stop memory collector is
emptied instead of freed and reallocated.
Added for Flatpak:
- `0010-new-mount-api-enosys.patch` - `open_tree`, `move_mount`, `fspick` and `mount_setattr`
answer `ENOSYS`. They take paths proot never translated, so libglnx's `open_tree(AT_FDCWD, "/")`
handed Flatpak a descriptor for the host's root and it tried to create directories there
(`mkdirat(root): Operation not permitted`); unsupported, libglnx falls back to `openat`.
- `0012-android-hardlink-denial.patch` - Android's SELinux policy denies apps hard links, so
`linkat` fails with `EACCES` and Flatpak could not create its repo (`Creating repo: linkat:
Permission denied`). `O_TMPFILE` answers `EOPNOTSUPP`, so libglnx writes a named temporary file
and renames it, and a denied link answers `EPERM`, on which ostree's checkout copies instead.
Added by DroidDeck:
- `0011-kompat-utsname-only.patch` - `--kernel-release` (the guest's `DroidDeck` hostname) loads
@@ -0,0 +1,105 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: DroidDeck <[email protected]>
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)
@@ -0,0 +1,82 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: DroidDeck <[email protected]>
Date: Tue, 29 Sep 2026 15:00:00 -0400
Subject: [PATCH] syscall: work around Android's denial of hard links
SELinux denies apps the file link permission, so linkat fails with
EACCES. libostree names its O_TMPFILE files with linkat and failed to
create the Flathub repo ("Creating repo: linkat: Permission denied").
O_TMPFILE now answers EOPNOTSUPP, so libglnx falls back to a named
temporary file and a rename, and a denied link answers EPERM, which
ostree's checkout takes as a filesystem without hard links and copies.
---
diff --git a/src/syscall/enter.c b/src/syscall/enter.c
index 8c3eb2e..4c28e10 100644
--- a/src/syscall/enter.c
+++ b/src/syscall/enter.c
@@ -400,6 +400,13 @@ int translate_syscall_enter(Tracee *tracee)
case PR_open:
flags = peek_reg(tracee, CURRENT, SYSARG_2);
+#ifdef __ANDROID__
+ /* An O_TMPFILE file can only be named by linkat, which Android denies apps. */
+ if ((flags & __O_TMPFILE) != 0) {
+ status = -EOPNOTSUPP;
+ break;
+ }
+#endif
if ( ((flags & O_NOFOLLOW) != 0)
|| ((flags & O_EXCL) != 0 && (flags & O_CREAT) != 0))
@@ -518,6 +525,12 @@ int translate_syscall_enter(Tracee *tracee)
case PR_openat:
dirfd = peek_reg(tracee, CURRENT, SYSARG_1);
flags = peek_reg(tracee, CURRENT, SYSARG_3);
+#ifdef __ANDROID__
+ if ((flags & __O_TMPFILE) != 0) {
+ status = -EOPNOTSUPP;
+ break;
+ }
+#endif
status = get_sysarg_path(tracee, path, SYSARG_2);
if (status < 0)
diff --git a/src/syscall/exit.c b/src/syscall/exit.c
index df41084..5d981db 100644
--- a/src/syscall/exit.c
+++ b/src/syscall/exit.c
@@ -544,6 +544,16 @@ void translate_syscall_exit(Tracee *tracee)
status = handle_statx_syscall(tracee, false);
break;
+#ifdef __ANDROID__
+ /* Android denies apps hard links with EACCES; EPERM is what callers such as
+ * libostree take as "no hard links here" and copy instead. */
+ case PR_link:
+ case PR_linkat:
+ if ((int) syscall_result == -EACCES)
+ poke_reg(tracee, SYSARG_RESULT, -EPERM);
+ goto end;
+#endif
+
case PR_ioctl:
if (peek_reg(tracee, ORIGINAL, SYSARG_2) == _IOW(0x94, 9, int) /* FICLONE */ &&
(int) peek_reg(tracee, CURRENT, SYSARG_RESULT) == -EACCES) {
diff --git a/src/syscall/seccomp.c b/src/syscall/seccomp.c
index 6d1ba47..8db9f45 100644
--- a/src/syscall/seccomp.c
+++ b/src/syscall/seccomp.c
@@ -362,8 +362,13 @@ static FilteredSysnum proot_sysnums[] = {
{ PR_lchown, 0 },
{ PR_lchown32, 0 },
{ PR_lgetxattr, 0 },
+#ifdef __ANDROID__
+ { PR_link, FILTER_SYSEXIT },
+ { PR_linkat, FILTER_SYSEXIT },
+#else
{ PR_link, 0 },
{ PR_linkat, 0 },
+#endif
{ PR_listxattr, 0 },
{ PR_llistxattr, 0 },
{ PR_lremovexattr, 0 },