mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-14 19:27:09 -04:00
Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ece38ddb8 | ||
|
|
9a4eeb3d06 | ||
|
|
85add46809 | ||
|
|
b78b06b78d | ||
|
|
58ec3609b6 | ||
|
|
abb3f5582b | ||
|
|
f5dfc946f2 | ||
|
|
a38952378d | ||
|
|
29dc88dce1 | ||
|
|
56889fe2ba | ||
|
|
463291d655 | ||
|
|
b0e02124d8 | ||
|
|
34640e83cb | ||
|
|
4e9cc0bb36 | ||
|
|
5c1b29f47a | ||
|
|
672b5dc74c | ||
|
|
76c82ee3ff | ||
|
|
15a541d745 | ||
|
|
fe04315461 | ||
|
|
4fef38e87e | ||
|
|
c63355be6c |
No files matched your search
@@ -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("&", "&").replace("<", "<").replace(">", ">").replace(""", "\"").replace("'", "'")
|
||||
.lines().map { it.trim().replace(Regex("\\s+"), " ") }
|
||||
.joinToString("\n").replace(Regex("\n{3,}"), "\n\n").trim()
|
||||
|
||||
private fun enc(s: String) = URLEncoder.encode(s, "UTF-8")
|
||||
|
||||
private fun get(url: String): String? = request(url, null)
|
||||
|
||||
private fun post(url: String, json: String): String? = request(url, json)
|
||||
|
||||
private fun request(url: String, json: String?): String? {
|
||||
var c: HttpURLConnection? = null
|
||||
return try {
|
||||
c = (URL(url).openConnection() as HttpURLConnection).apply {
|
||||
connectTimeout = TIMEOUT_MS
|
||||
readTimeout = TIMEOUT_MS
|
||||
setRequestProperty("Accept", "application/json")
|
||||
if (json != null) {
|
||||
requestMethod = "POST"
|
||||
doOutput = true
|
||||
setRequestProperty("Content-Type", "application/json")
|
||||
outputStream.use { it.write(json.toByteArray()) }
|
||||
}
|
||||
}
|
||||
if (c.responseCode / 100 != 2) { Log.w(TAG, "$url -> HTTP ${c.responseCode}"); null }
|
||||
else c.inputStream.bufferedReader().use { it.readText() }
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "$url: $e"); null
|
||||
} finally {
|
||||
c?.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 & 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"))
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
Reference in new issue
Block a user