Feature adding custom Linux applications (#130)

* Add your own apps to the Desktop page

An Add tile on Desktop takes a .sh script, an AppImage, a GitHub repo
(newest ARM64 AppImage release) or a Flatpak ID, with an optional name
and icon. Script folders are linked into the session, or copied when
they hold Linux programs (shared storage is noexec); folders outside
internal storage are bound at their own path.

Each app's page opens it under gamescope, edits its name and icon (with
icons suggested from its GitHub repo), checks a GitHub app for a newer
release and updates it in place, or removes it. Replaces the beta
AppImages section.

* Put Add at the end of the installable emulators

Installed emulators and added apps keep the top section, shown once there is one. Add closes the installable grid, or the installed one when nothing is left to install.

* Import DwarFS AppImages and their SVG icons

uruntime images built with sharun keep one program under several names
as hard links, which Android denies apps, so dwarfsextract aborted. The
extraction runs with proot's link2symlink, and each linked name becomes
a file of its own afterwards; their AppDir is followed through the
squashfs-root link.

An image's icon is a PNG of at least 128 px from the name its entry
gives, else its SVG (drawn with AndroidSVG), else the largest PNG, else
.DirIcon.

* Give AppImages the session's pads

sharun images start the app through their own ld.so, patched not to read
/etc/ld.so.preload, and drop LD_PRELOAD unless allowed, so the app never
loaded libfakeinput and saw no controller. The launcher now hands the
preload list over in LD_PRELOAD with SHARUN_ALLOW_LD_PRELOAD set.

Pad defaults also put Player 1 on the controller for yuzu-family
emulators, once, while it is still on the keyboard.

* Redesign the Add and Edit app dialogs

Source and appearance sit in two panels side by side, with a live
preview of the Desktop tile. A disabled primary button keeps an outline
so it still reads as a button.
This commit is contained in:
MaxsTechReview authored and GitHub committed 2026-10-01 18:23:47 -04:00
1 parent d506394f87
commit e31801df35
22 files changed
+1589 -265

No files matched your search

+1
View File
@@ -190,6 +190,7 @@ dependencies {
// copies on. Same versions as Bannerlator so the screen stays a straight copy.
implementation 'androidx.compose.material:material-icons-extended'
implementation 'io.coil-kt:coil-compose:2.6.0'
implementation 'com.caverock:androidsvg-aar:1.4'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
// ADB-over-Wi-Fi pairing and authenticated shell client. Users still approve pairing from
// Android's Wireless debugging screen; DroidDeck only runs its one fixed setting command.
@@ -148,7 +148,6 @@ class MainActivity : ComponentActivity() {
private var stretch16x9 by mutableStateOf(false)
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
@@ -247,9 +246,6 @@ 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)
@@ -344,7 +340,6 @@ class MainActivity : ComponentActivity() {
backActionsInverted = SessionPrefs.backActionsInverted(this)
launcherFullscreen = SessionPrefs.launcherFullscreen(this)
storeEnabled = SessionPrefs.storeEnabled(this)
appImagesEnabled = SessionPrefs.appImagesEnabled(this)
applyLauncherFullscreen()
updates.start()
setContent {
@@ -392,7 +387,6 @@ class MainActivity : ComponentActivity() {
showPhantomGate = showPhantomGate,
launcherFullscreen = launcherFullscreen,
storeEnabled = storeEnabled,
appImagesEnabled = appImagesEnabled,
),
FrontEndActions(
onPlay = { startSteamSession() },
@@ -412,18 +406,15 @@ 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
// An app added on the Desktop page, full screen under gamescope like an emulator.
onUserApp = { app ->
app.args.firstOrNull()?.let { Library.flatpakNames[it] = app.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)))
.putExtra(SessionService.EXTRA_PROGRAM, app.program)
.putExtra(SessionService.EXTRA_PROGRAM_ARGS, app.args.toTypedArray()))
},
// A Flatpak app from the store, full screen under gamescope like an emulator.
onFlatpakApp = { id, name ->
Library.flatpakNames[id] = name
startActivity(Intent(this, SessionActivity::class.java)
@@ -499,7 +490,6 @@ class MainActivity : ComponentActivity() {
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)
@@ -172,11 +172,12 @@ object Library {
/** The emulator's name for a program path from the rail ("ARMSX2"), or null. */
fun nameForProgram(program: String?): String? =
if (program == com.droiddeck.launcher.runtime.FlatpakManager.LAUNCHER || program == com.droiddeck.launcher.runtime.AppImageManager.LAUNCHER) {
if (program == com.droiddeck.launcher.runtime.FlatpakManager.LAUNCHER || program == com.droiddeck.launcher.runtime.AppImageManager.LAUNCHER ||
program == com.droiddeck.launcher.runtime.UserApps.SCRIPT_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. */
/** Added apps' names by Flatpak id or directory, as launched: the session only knows that. */
val flatpakNames = java.util.concurrent.ConcurrentHashMap<String, String>()
/**
@@ -1,7 +1,9 @@
package com.droiddeck.launcher.runtime
import android.content.Context
import android.graphics.BitmapFactory
import android.os.StatFs
import android.system.Os
import android.util.Log
import com.droiddeck.launcher.core.FileUtils
import java.io.File
@@ -21,6 +23,7 @@ object AppImageManager {
const val LAUNCHER = "/usr/local/bin/bannerlator-appimage-run"
private const val ELF_AARCH64 = 183
private const val ELF_X86_64 = 62
private const val SHARP_ICON = 128
class Item(val id: String, val name: String, val icon: File?, val comment: String?) {
val guestDir: String get() = "$GUEST_DIR/$id"
@@ -74,8 +77,15 @@ object AppImageManager {
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? {
fun dir(context: Context, id: String) = File(root(context), id)
/**
* Imports [file]; null on success, else what went wrong. [name] and [icon] replace the image's own;
* each of [meta] is written as a file beside them.
*/
fun import(
context: Context, file: File, name: String?, icon: File?, meta: Map<String, String> = emptyMap(), 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() }
@@ -83,9 +93,45 @@ object AppImageManager {
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 dir = File(base, idFor(name ?: file.name, base.list()?.toSet() ?: emptySet()))
try {
extract(context, dir, file, onProgress)?.let { FileUtils.delete(dir); return it }
describe(dir, file)
if (name != null) FileUtils.writeString(File(dir, "name"), name)
if (icon != null && !UserApps.saveIcon(icon, File(dir, "icon.png"))) Log.w(TAG, "icon ${icon.path} could not be read")
meta.forEach { (k, v) -> FileUtils.writeString(File(dir, k), v) }
writeMenuEntry(context, dir)
return null
} catch (e: Exception) {
Log.e(TAG, "import ${file.name}", e)
FileUtils.delete(dir)
return e.message ?: "Import failed"
}
}
/** Swaps the program of imported [id] for [file], keeping its name and icon; the old one stays if this fails. */
fun replace(context: Context, id: String, file: File, meta: Map<String, String>, onProgress: (String) -> Unit): String? {
problem(file)?.let { return it }
val dir = dir(context, id)
if (!File(dir, "app").isDirectory) return "That AppImage is gone"
if (StatFs(dir.path).availableBytes < file.length() * 3) {
return "Not enough free space: updating needs about ${FileUtils.sizeToString(file.length() * 3)}"
}
return try {
extract(context, dir, file, onProgress) ?: run {
meta.forEach { (k, v) -> FileUtils.writeString(File(dir, k), v) }
null
}
} catch (e: Exception) {
Log.e(TAG, "replace $id", e)
e.message ?: "Update failed"
}
}
/** Extracts [file] to [dir]/app, replacing what is there only once the extraction has worked. */
private fun extract(context: Context, dir: File, file: File, onProgress: (String) -> Unit): String? {
val image = File(dir, "image.AppImage")
val links = File(dir, ".links").apply { mkdirs() }
try {
dir.mkdirs()
// Copied in, not run where it lies: shared storage is mounted noexec, and proot's loader
@@ -94,27 +140,41 @@ object AppImageManager {
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()
// uruntime's DwarFS images unpack into AppDir with squashfs-root a link to it, and keep
// one program under several names as hard links (sharun), which Android denies apps.
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) }
"cd \"$1\" && rm -rf squashfs-root AppDir && ./image.AppImage --appimage-extract >/dev/null && " +
"rm -rf app && mv \"$(readlink -f squashfs-root)\" app && rm -f squashfs-root",
"extract", "$GUEST_DIR/${dir.name}",
), logName = "appimage-import", linkDir = links) { line -> if (out.length < 2000) out.appendLine(line) }
if (status != 0 || !File(dir, "app").isDirectory) {
FileUtils.delete(dir)
listOf("squashfs-root", "AppDir").forEach { FileUtils.delete(File(dir, it)) }
return "The AppImage could not be extracted" + (out.lines().lastOrNull { it.isNotBlank() }?.let { ": $it" } ?: "")
}
image.delete()
describe(dir, file)
writeMenuEntry(context, dir)
copyLinkedFiles(File(dir, "app"), links)
return null
} catch (e: Exception) {
Log.e(TAG, "import ${file.name}", e)
FileUtils.delete(dir)
return e.message ?: "Import failed"
} finally {
image.delete()
FileUtils.delete(links)
}
}
/**
* Each name proot's link2symlink left for a hard link becomes a file of its own: a program run
* through a link would see the shared file's name, and sharun picks what to start by its own.
*/
private fun copyLinkedFiles(app: File, links: File) {
val roots = setOf(links.path + "/", links.canonicalPath + "/")
app.walkTopDown().onEnter { !Files.isLink(it) }.forEach { f ->
if (!Files.isLink(f)) return@forEach
val target = runCatching { Os.readlink(f.path) }.getOrNull() ?: return@forEach
if (roots.none { target.startsWith(it) }) return@forEach
val data = f.toPath().toRealPath()
java.nio.file.Files.delete(f.toPath())
java.nio.file.Files.copy(data, f.toPath())
if (java.nio.file.Files.isExecutable(data)) f.setExecutable(true, false)
}
}
@@ -128,14 +188,32 @@ object AppImageManager {
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)
restoreIcon(dir)
}
/**
* The image's own icon as [dir]'s: of the files its entry names, beside it or anywhere in its
* icon theme, a PNG of at least [SHARP_ICON] px, else the SVG, else the largest PNG; .DirIcon
* without any.
*/
internal fun restoreIcon(dir: File) {
val app = File(dir, "app")
val entry = app.listFiles { f -> f.isFile && f.name.endsWith(".desktop") }?.firstOrNull()
val iconName = entry?.let { FileUtils.readString(it) }?.let { desktopKey(it, "Icon") }
val named = iconName?.let { n ->
app.walkTopDown().maxDepth(8).filter { it.isFile && (it.name == "$n.png" || it.name == "$n.svg") }.toList()
}.orEmpty()
val png = named.filter(::isPng).maxByOrNull(::pngSide)
val svg = named.firstOrNull { it.name.endsWith(".svg") }
val pick = png?.takeIf { pngSide(it) >= SHARP_ICON } ?: svg ?: png ?: File(app, ".DirIcon").takeIf { it.isFile }
val icon = File(dir, "icon.png")
if (pick == null || !UserApps.saveIcon(pick, icon)) icon.delete()
}
private fun pngSide(f: File): Int {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(f.path, bounds)
return minOf(bounds.outWidth, bounds.outHeight)
}
private fun isPng(f: File): Boolean = try {
@@ -159,7 +237,7 @@ object AppImageManager {
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) {
internal 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;"
@@ -89,7 +89,7 @@ object FlatpakManager {
/** 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)
return GuestCommand.run(context, argv, fakeRoot, name, onLine = onLine)
}
private inline fun <T> exclusive(what: String, block: () -> T): T? {
@@ -18,10 +18,11 @@ 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/tools/<logName>.log beside the session logs, so a problem can
* be handed over like a session's.
* be handed over like a session's. With [linkDir], hard links (which Android denies apps)
* become symlinks to files proot keeps there (its link2symlink).
*/
fun run(context: Context, argv: List<String>, fakeRoot: Boolean = false, logName: String? = null,
onLine: (String) -> Unit): Int {
linkDir: File? = null, onLine: (String) -> Unit): Int {
val log = logName?.let {
try {
File(File(LinuxRuntime.debugLogDir(), SessionPaths.TOOLS_DIR).apply { mkdirs() }, "$it.log").printWriter()
@@ -29,13 +30,13 @@ object GuestCommand {
}
log?.println("== ${java.util.Date()} ${argv.joinToString(" ")}")
try {
return runLogged(context, argv, fakeRoot) { line -> log?.println(line); log?.flush(); onLine(line) }
return runLogged(context, argv, fakeRoot, linkDir) { 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 {
private fun runLogged(context: Context, argv: List<String>, fakeRoot: Boolean, linkDir: File?, onLine: (String) -> Unit): Int {
val root = LinuxRuntime.rootDir(context)
LinuxRuntime.writeAccounts(context)
SessionFiles.stage(context, root)
@@ -43,6 +44,7 @@ object GuestCommand {
LinuxNetworkLinkComponent(context, root).publish()
val runtimeDir = File(context.filesDir, ".flatpak-rt").apply { mkdirs() }
val cmd = LinuxRuntime.prootPrefix(context, root, "/root", fakeRoot)
if (linkDir != null) cmd.add(1, "--link2symlink")
LinuxRuntime.binds(context, null, runtimeDir, Environment.getExternalStorageDirectory(), null)
.forEach { cmd.add("-b"); cmd.add(it) }
cmd += listOf(
@@ -56,6 +58,7 @@ object GuestCommand {
builder.environment().apply {
put("PROOT_LOADER", LinuxRuntime.prootLoader(context).path)
put("PROOT_TMP_DIR", context.cacheDir.path)
if (linkDir != null) put("PROOT_L2S_DIR", linkDir.path)
if (SessionPrefs.prootNoSeccomp(context)) put("PROOT_NO_SECCOMP", "1")
LinuxRuntime.prootLibraryPath(context).takeIf { it.isNotEmpty() }?.let { put("LD_LIBRARY_PATH", it) }
}
@@ -0,0 +1,422 @@
package com.droiddeck.launcher.runtime
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.net.Uri
import android.os.Environment
import android.os.StatFs
import android.system.Os
import android.util.Log
import com.caverock.androidsvg.SVG
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.Downloader
import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.store.FlathubApi
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.io.RandomAccessFile
/**
* The apps a user adds to the Desktop page: shell scripts, AppImages (from storage or a GitHub
* release) and Flatpak apps. Each opens full screen under gamescope, like an emulator.
*
* A script lives at /opt/droiddeck-apps/<id>: its folder at `files`, the script's name in `entry`.
* The folder is linked where it lies; shared storage is mounted noexec, so a folder holding Linux
* programs is copied in instead. A linked folder outside internal storage is bound into the
* session at its own path ([binds]), so the link resolves inside the guest.
*/
object UserApps {
private const val TAG = "UserApps"
const val GUEST_DIR = "/opt/droiddeck-apps"
const val SCRIPT_LAUNCHER = "/usr/local/bin/bannerlator-script-run"
private const val FLATPAK_OVERRIDES = ".flatpak"
private const val ICON_SIZE = 256
private const val ELF_SCAN_LIMIT = 4000
private const val ICON_SUGGESTIONS = 7
private const val ICON_FILE_LIMIT = 2L shl 20
private val ICON_TYPES = setOf("png", "jpg", "jpeg", "webp")
private val IGNORED_DIRS = Regex("(^|/)(node_modules|vendor|third[_-]?party|external|tests?|docs?/screenshots)/", RegexOption.IGNORE_CASE)
private const val GITHUB_REPO = "github-repo"
private const val GITHUB_TAG = "github-tag"
private const val GITHUB_PUBLISHED = "github-published"
enum class Kind { SCRIPT, APPIMAGE, FLATPAK }
/**
* [copied]: a script whose folder was copied in rather than linked. [repo]: the GitHub repository
* an AppImage came from, [version] the release it is.
*/
class App(
val key: String, val kind: Kind, val name: String, val icon: Any?, val detail: String?,
val program: String, val args: List<String>, val copied: Boolean = false,
val repo: String? = null, val version: String? = null,
)
class Release(val tag: String, val published: String, val asset: String, val url: String)
sealed class UpdateCheck {
class Current(val tag: String) : UpdateCheck()
class Available(val release: Release) : UpdateCheck()
class Failed(val message: String) : UpdateCheck()
}
sealed class Source {
class Script(val file: File) : Source()
class AppImage(val file: File) : Source()
class GitHub(val repo: String) : Source()
class Flatpak(val id: String) : Source()
}
/** [icon]: a picked file's path or a suggested icon's URL. */
class Request(val source: Source, val name: String?, val icon: String?)
/** How a script's folder reaches the session: linked in place, or copied in ([bytes] to copy). */
class ScriptPlan(val folder: File, val copy: Boolean, val bytes: Long)
private fun root(context: Context) = File(LinuxRuntime.rootDir(context), GUEST_DIR.substring(1))
private fun overrides(context: Context, flatpakId: String) = File(root(context), "$FLATPAK_OVERRIDES/$flatpakId")
private fun menuEntry(context: Context, id: String) =
File(LinuxRuntime.rootDir(context), "usr/local/share/applications/droiddeck-app-$id.desktop")
fun list(context: Context): List<App> {
val scripts = root(context).listFiles()?.filter { File(it, "entry").isFile }?.map { dir ->
App(
"script:${dir.name}", Kind.SCRIPT, readName(dir) ?: dir.name, File(dir, "icon.png").takeIf { it.isFile },
FileUtils.readString(File(dir, "source"))?.trim(), SCRIPT_LAUNCHER, listOf("$GUEST_DIR/${dir.name}"),
copied = !java.nio.file.Files.isSymbolicLink(File(dir, "files").toPath()),
)
}.orEmpty()
val images = AppImageManager.list(context).map { item ->
val dir = AppImageManager.dir(context, item.id)
App(
"appimage:${item.id}", Kind.APPIMAGE, item.name, item.icon, item.comment, AppImageManager.LAUNCHER, listOf(item.guestDir),
repo = FileUtils.readString(File(dir, GITHUB_REPO))?.trim(), version = FileUtils.readString(File(dir, GITHUB_TAG))?.trim(),
)
}
val flatpaks = FlatpakManager.installedApps(context).map { app ->
val custom = overrides(context, app.id)
App(
"flatpak:${app.id}", Kind.FLATPAK, readName(custom) ?: app.name,
File(custom, "icon.png").takeIf { it.isFile } ?: app.icon ?: FlathubApi.iconUrl(app.id),
app.summary, FlatpakManager.LAUNCHER, listOf(app.id),
)
}
return (scripts + images + flatpaks).sortedBy { it.name.lowercase() }
}
private fun readName(dir: File) = FileUtils.readString(File(dir, "name"))?.trim()?.takeIf { it.isNotEmpty() }
/** Adds [request]; null on success, else what went wrong. */
fun add(context: Context, request: Request, onProgress: (String, Int) -> Unit): String? {
if (!LinuxRuntime.isInstalled(context)) return context.getString(R.string.user_apps_runtime_required)
val name = request.name?.trim()?.takeIf { it.isNotEmpty() }
return withIcon(context, request.icon) { icon ->
when (val s = request.source) {
is Source.Script -> addScript(context, s.file, name, icon, onProgress)
is Source.AppImage -> AppImageManager.import(context, s.file, name, icon) { onProgress(it, -1) }
is Source.GitHub -> addFromGitHub(context, s.repo, name, icon, onProgress)
is Source.Flatpak -> addFlatpak(context, s.id, name, icon, onProgress)
}
}
}
/** [icon] as a file for [use]: a path as it is, a URL downloaded for the while. */
private inline fun <T> withIcon(context: Context, icon: String?, use: (File?) -> T): T {
if (icon == null || !icon.startsWith("https://")) return use(icon?.let(::File))
val download = File(context.cacheDir, "icon-${System.nanoTime()}")
try {
return use(download.takeIf { Downloader.downloadFile(icon, it, false) { } })
} finally {
download.delete()
}
}
/**
* Renames [app] and sets its icon: [icon] a path or URL, "" for its default, null to keep it.
* The Linux desktop's menu entry follows.
*/
fun edit(context: Context, app: App, name: String, icon: String?): String? {
val id = app.key.substringAfter(':')
if (id.isEmpty() || '/' in id || id.startsWith(".")) return null
val dir = when (app.kind) {
Kind.SCRIPT -> File(root(context), id)
Kind.APPIMAGE -> AppImageManager.dir(context, id)
Kind.FLATPAK -> overrides(context, id)
}.apply { mkdirs() }
FileUtils.writeString(File(dir, "name"), name.trim())
val target = File(dir, "icon.png")
when {
icon == null -> {}
icon.isEmpty() -> if (app.kind == Kind.APPIMAGE) AppImageManager.restoreIcon(dir) else target.delete()
else -> withIcon(context, icon) { file -> if (file == null || !saveIcon(file, target)) return context.getString(R.string.user_apps_icon_failed) }
}
when (app.kind) {
Kind.SCRIPT -> writeScriptEntry(context, dir)
Kind.APPIMAGE -> AppImageManager.writeMenuEntry(context, dir)
Kind.FLATPAK -> {}
}
return null
}
fun remove(context: Context, app: App, onProgress: (String, Int) -> Unit): String? {
val id = app.key.substringAfter(':')
if (id.isEmpty() || '/' in id || id.startsWith(".")) return null
return when (app.kind) {
Kind.SCRIPT -> {
val dir = File(root(context), id)
// The link goes, never the folder it points to.
File(dir, "files").delete()
FileUtils.delete(dir)
menuEntry(context, id).delete()
null
}
Kind.APPIMAGE -> { AppImageManager.remove(context, id); null }
Kind.FLATPAK -> FlatpakManager.uninstall(context, id, onProgress).also {
if (it == null) FileUtils.delete(overrides(context, id))
}
}
}
/** Folders linked from outside internal storage, bound into a session at their own paths. */
fun binds(context: Context): List<String> {
val internal = Environment.getExternalStorageDirectory().canonicalPath
return root(context).listFiles().orEmpty().mapNotNull { dir ->
val target = runCatching { Os.readlink(File(dir, "files").path) }.getOrNull() ?: return@mapNotNull null
target.takeIf { !it.startsWith("$internal/") && File(it).isDirectory }
}.distinct()
}
fun planScript(script: File): ScriptPlan {
val folder = script.canonicalFile.parentFile ?: script.parentFile!!
var scanned = 0
val copy = folder.walkTopDown().maxDepth(4).filter { it.isFile }.takeWhile { scanned++ < ELF_SCAN_LIMIT }.any(::isElf)
val bytes = if (copy) folder.walkTopDown().filter { it.isFile }.sumOf { it.length() } else 0L
return ScriptPlan(folder, copy, bytes)
}
private fun isElf(f: File): Boolean = try {
RandomAccessFile(f, "r").use { val b = ByteArray(4); it.read(b) == 4 && b[0] == 0x7f.toByte() && b[1] == 'E'.code.toByte() && b[2] == 'L'.code.toByte() && b[3] == 'F'.code.toByte() }
} catch (e: Exception) { false }
private fun addScript(context: Context, script: File, name: String?, icon: File?, onProgress: (String, Int) -> Unit): String? {
if (!script.isFile) return context.getString(R.string.user_apps_file_gone)
val plan = planScript(script)
val base = root(context).apply { mkdirs() }
if (plan.copy && StatFs(base.path).availableBytes < plan.bytes + (64L shl 20)) {
return context.getString(R.string.user_apps_no_space, FileUtils.sizeToString(plan.bytes))
}
val title = name ?: script.nameWithoutExtension
val id = AppImageManager.idFor(title, base.list()?.toSet() ?: emptySet())
val dir = File(base, id)
try {
dir.mkdirs()
val files = File(dir, "files")
if (plan.copy) {
onProgress(context.getString(R.string.user_apps_copying, plan.folder.name), -1)
copyTree(plan.folder, files)
} else {
Os.symlink(plan.folder.path, files.path)
}
FileUtils.writeString(File(dir, "entry"), script.name)
FileUtils.writeString(File(dir, "name"), title)
FileUtils.writeString(File(dir, "source"), script.canonicalPath)
if (icon != null && !saveIcon(icon, File(dir, "icon.png"))) Log.w(TAG, "icon ${icon.path} could not be read")
writeScriptEntry(context, dir)
return null
} catch (e: Exception) {
Log.e(TAG, "script ${script.path}", e)
File(dir, "files").takeIf { !plan.copy }?.delete()
FileUtils.delete(dir)
return e.message ?: context.getString(R.string.user_apps_failed)
}
}
private fun writeScriptEntry(context: Context, dir: File) {
val id = dir.name
val icon = if (File(dir, "icon.png").isFile) "$GUEST_DIR/$id/icon.png" else "utilities-terminal"
FileUtils.writeString(menuEntry(context, id),
"[Desktop Entry]\nType=Application\nName=${readName(dir) ?: id}\nExec=$SCRIPT_LAUNCHER $GUEST_DIR/$id\nIcon=$icon\n" +
"Terminal=false\nCategories=Game;\nX-DroidDeck-App=$id\n")
}
/** Copies [from] into [to], keeping Linux programs and scripts executable. */
private fun copyTree(from: File, to: File) {
from.walkTopDown().forEach { src ->
val dst = File(to, src.relativeTo(from).path)
if (src.isDirectory) dst.mkdirs()
else {
src.inputStream().use { input -> dst.outputStream().use { FileUtils.copy(input, it) } }
if (isElf(dst) || dst.extension == "sh") dst.setExecutable(true, false)
}
}
}
/** [source] (PNG, JPEG, WebP or SVG) as the app's icon: a PNG no larger than [ICON_SIZE] on a side. */
internal fun saveIcon(source: File, target: File): Boolean {
val bitmap = (if (isSvg(source)) renderSvg(source) else decodeScaled(source)) ?: return false
return try {
target.parentFile?.mkdirs()
target.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) }
} catch (e: Exception) {
Log.w(TAG, "icon ${source.path}", e); false
} finally {
bitmap.recycle()
}
}
private fun decodeScaled(source: File): Bitmap? {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(source.path, bounds)
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
var sample = 1
while (maxOf(bounds.outWidth, bounds.outHeight) / (sample * 2) >= ICON_SIZE) sample *= 2
val decoded = BitmapFactory.decodeFile(source.path, BitmapFactory.Options().apply { inSampleSize = sample }) ?: return null
val scale = ICON_SIZE.toFloat() / maxOf(decoded.width, decoded.height)
if (scale >= 1f) return decoded
val scaled = Bitmap.createScaledBitmap(decoded, (decoded.width * scale).toInt().coerceAtLeast(1), (decoded.height * scale).toInt().coerceAtLeast(1), true)
if (scaled !== decoded) decoded.recycle()
return scaled
}
private fun isSvg(f: File): Boolean = try {
f.inputStream().use { val head = ByteArray(1024); val n = it.read(head); n > 0 && String(head, 0, n).contains("<svg") }
} catch (e: Exception) { false }
/** An SVG drawn at [ICON_SIZE], centred in its own proportions. */
private fun renderSvg(source: File): Bitmap? = try {
val svg = source.inputStream().use { SVG.getFromInputStream(it) }
if (svg.documentViewBox == null && svg.documentWidth > 0 && svg.documentHeight > 0) {
svg.setDocumentViewBox(0f, 0f, svg.documentWidth, svg.documentHeight)
}
svg.setDocumentWidth("100%")
svg.setDocumentHeight("100%")
Bitmap.createBitmap(ICON_SIZE, ICON_SIZE, Bitmap.Config.ARGB_8888).also { svg.renderToCanvas(Canvas(it)) }
} catch (e: Exception) {
Log.w(TAG, "svg ${source.path}", e); null
}
/** "owner/repo" from what was typed: any link into the repository, its clone address, or the short form. */
fun githubRepo(input: String): String? {
val t = input.trim()
val m = Regex("^(?:https?://)?(?:www\\.)?github\\.com[/:]([\\w.-]+)/([\\w.-]+)", RegexOption.IGNORE_CASE).find(t)
?: Regex("^git@github\\.com:([\\w.-]+)/([\\w.-]+)", RegexOption.IGNORE_CASE).find(t)
?: Regex("^([\\w.-]+)/([\\w.-]+)/?$").find(t) ?: return null
val repo = m.groupValues[2].removeSuffix(".git")
return if (repo.isEmpty() || repo == "." || repo == "..") null else "${m.groupValues[1]}/$repo"
}
/** The ARM64 AppImage among a release's files: named for aarch64/arm64, else for no other CPU. */
internal fun pickAsset(names: List<String>): String? {
val images = names.filter { it.endsWith(".appimage", ignoreCase = true) }
return images.firstOrNull { Regex("aarch64|arm64", RegexOption.IGNORE_CASE).containsMatchIn(it) }
?: images.firstOrNull { !Regex("x86_64|x86-64|amd64|x64|i[36]86|armhf|armv7", RegexOption.IGNORE_CASE).containsMatchIn(it) }
}
/** The newest release of [repo] with an ARM64 AppImage, preferring full releases; else what went wrong. */
private fun latestRelease(context: Context, repo: String): Pair<Release?, String?> {
val body = Downloader.downloadString("https://api.github.com/repos/$repo/releases?per_page=15")
val releases = body?.let { runCatching { JSONArray(it) }.getOrNull() }
?: return null to context.getString(R.string.user_apps_github_unreachable, repo)
val candidates = (0 until releases.length()).mapNotNull { i ->
val r = releases.getJSONObject(i)
if (r.optBoolean("draft")) return@mapNotNull null
val assets = r.optJSONArray("assets") ?: return@mapNotNull null
val byName = (0 until assets.length()).associate { j -> assets.getJSONObject(j).let { it.getString("name") to it.getString("browser_download_url") } }
pickAsset(byName.keys.toList())?.let { asset ->
r.optBoolean("prerelease") to Release(r.optString("tag_name", asset), r.optString("published_at"), asset, byName.getValue(asset))
}
}
val pick = (candidates.firstOrNull { !it.first } ?: candidates.firstOrNull())?.second
return pick to if (pick == null) context.getString(R.string.user_apps_github_no_appimage, repo) else null
}
/** Downloads [release] and hands it to [use]; null on success, else what went wrong. */
private fun withDownload(context: Context, release: Release, onProgress: (String, Int) -> Unit, use: (File) -> String?): String? {
val download = File(context.cacheDir, "github-${release.asset}")
try {
val label = context.getString(R.string.user_apps_downloading, release.asset)
val ok = Downloader.downloadFile(release.url, download, false) { f -> onProgress(label, if (f < 0) -1 else Math.round(f * 100f)) }
return if (ok) use(download) else context.getString(R.string.user_apps_download_failed)
} finally {
download.delete()
}
}
private fun githubMeta(repo: String, release: Release) =
mapOf(GITHUB_REPO to repo, GITHUB_TAG to release.tag, GITHUB_PUBLISHED to release.published)
private fun addFromGitHub(context: Context, repo: String, name: String?, icon: File?, onProgress: (String, Int) -> Unit): String? {
onProgress(context.getString(R.string.user_apps_github_checking, repo), -1)
val (release, problem) = latestRelease(context, repo)
if (release == null) return problem
return withDownload(context, release, onProgress) { file ->
AppImageManager.import(context, file, name ?: repo.substringAfter('/'), icon, githubMeta(repo, release)) { onProgress(it, -1) }
}
}
/**
* Images in [repo] that look like its icon, best first, then its owner's picture: links to show
* as suggestions and to download once one is picked.
*/
fun githubIcons(repo: String): List<String> {
val info = Downloader.downloadString("https://api.github.com/repos/$repo")?.let { runCatching { JSONObject(it) }.getOrNull() } ?: return emptyList()
val branch = info.optString("default_branch").ifEmpty { return emptyList() }
val avatar = info.optJSONObject("owner")?.optString("avatar_url")?.takeIf { it.startsWith("https://") }
val tree = Downloader.downloadString("https://api.github.com/repos/$repo/git/trees/${Uri.encode(branch)}?recursive=1")
?.let { runCatching { JSONObject(it).optJSONArray("tree") }.getOrNull() }
val project = repo.substringAfter('/').lowercase().replace(Regex("[^a-z0-9]"), "")
val images = (0 until (tree?.length() ?: 0)).mapNotNull { i ->
val entry = tree!!.getJSONObject(i)
val path = entry.optString("path")
val size = entry.optLong("size")
val file = path.substringAfterLast('/').lowercase()
val stem = file.substringBeforeLast('.').replace(Regex("[^a-z0-9]"), "")
if (entry.optString("type") != "blob" || size !in 512..ICON_FILE_LIMIT || file.substringAfterLast('.') !in ICON_TYPES) return@mapNotNull null
if (IGNORED_DIRS.containsMatchIn(path)) return@mapNotNull null
val score = listOf("icon" in stem, "logo" in stem, project.isNotEmpty() && project in stem, "icon" in path.lowercase()).count { it }
if (score == 0) null else Triple(path, score, size)
}.sortedWith(compareByDescending<Triple<String, Int, Long>> { it.second }.thenByDescending { it.third })
val urls = images.take(ICON_SUGGESTIONS).map { (path, _, _) ->
"https://raw.githubusercontent.com/$repo/${Uri.encode(branch)}/" + path.split('/').joinToString("/") { Uri.encode(it) }
}
return urls + listOfNotNull(avatar)
}
/** Whether [app], added from GitHub, has a newer release than the one it is. */
fun checkUpdate(context: Context, app: App): UpdateCheck {
val repo = app.repo ?: return UpdateCheck.Failed(context.getString(R.string.user_apps_update_failed))
val (release, problem) = latestRelease(context, repo)
if (release == null) return UpdateCheck.Failed(problem ?: context.getString(R.string.user_apps_update_failed))
val published = FileUtils.readString(File(AppImageManager.dir(context, app.key.substringAfter(':')), GITHUB_PUBLISHED))?.trim().orEmpty()
// Releases are compared by date, so a newest one without an ARM64 build never reads as a downgrade.
val newer = if (published.isNotEmpty() && release.published.isNotEmpty()) release.published > published else release.tag != app.version
return if (newer) UpdateCheck.Available(release) else UpdateCheck.Current(app.version ?: release.tag)
}
fun update(context: Context, app: App, release: Release, onProgress: (String, Int) -> Unit): String? {
val repo = app.repo ?: return context.getString(R.string.user_apps_update_failed)
return withDownload(context, release, onProgress) { file ->
AppImageManager.replace(context, app.key.substringAfter(':'), file, githubMeta(repo, release)) { onProgress(it, -1) }
}
}
/** A Flathub app ID from what was typed: the ID itself or its Flathub page. */
fun flatpakId(input: String): String? {
val t = input.trim().removeSuffix("/").substringAfterLast('/')
return t.takeIf { Regex("^[A-Za-z][\\w-]*(\\.[A-Za-z0-9_][\\w-]*){2,}$").matches(it) }
}
private fun addFlatpak(context: Context, id: String, name: String?, icon: File?, onProgress: (String, Int) -> Unit): String? {
if (!FlatpakManager.ready(context)) FlatpakManager.setup(context, onProgress)?.let { return it }
FlatpakManager.install(context, id, onProgress)?.let { return it }
val custom = overrides(context, id)
if (name != null) FileUtils.writeString(File(custom.apply { mkdirs() }, "name"), name)
if (icon != null && !saveIcon(icon, File(custom, "icon.png"))) Log.w(TAG, "icon ${icon.path} could not be read")
return null
}
}
@@ -51,6 +51,7 @@ object SessionFiles {
"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",
"usr/local/bin/bannerlator-script-run" to "usr/local/bin/bannerlator-script-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,20 +30,13 @@ 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. */
/** The Flathub Store (beta): its rail item. 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()
}
/**
* The session's performance HUD (the fps box). A Deck-mode Steam session with the performance
* overlay has Steam's own (mangoapp, from the QAM), so there the HUD is off unless turned on
@@ -782,6 +782,8 @@ class SessionService : Service() {
Log.w(TAG, "added games: ${root.host} is not a readable folder this session")
}
}
// Folders of added scripts outside internal storage, where their links point.
binds.addAll(com.droiddeck.launcher.runtime.UserApps.binds(this))
val roms = SessionPrefs.romsDir(this).takeIf { it.isNotEmpty() }?.let { File(it) }
if (roms != null && roms.isDirectory && roms.canRead()) {
File(home, "ROMs").mkdirs()
@@ -1,63 +0,0 @@
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,99 @@
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.R
import com.droiddeck.launcher.runtime.UserApps
/**
* The Desktop page's added apps and the one add or remove in progress. State changes only on the
* main thread; the work runs on a thread of its own and posts back.
*/
object UserAppsState {
private val main = Handler(Looper.getMainLooper())
var items by mutableStateOf<List<UserApps.App>>(emptyList())
private set
/** What is being added or removed; null when idle. */
var working by mutableStateOf<String?>(null)
private set
var stage by mutableStateOf<String?>(null)
private set
var percent by mutableStateOf(-1)
private set
/** Why the last add or remove failed, shown on the page until the next one. */
var lastError by mutableStateOf<String?>(null)
private set
/** The app whose updates are being looked up, and what each lookup found, by app key. */
var checking by mutableStateOf<String?>(null)
private set
var updates by mutableStateOf<Map<String, UserApps.UpdateCheck>>(emptyMap())
private set
/** Bumped per listing; only the newest one is shown. */
private var listing = 0
fun refresh(context: Context) {
val app = context.applicationContext
val seq = ++listing
Thread({
val list = runCatching { UserApps.list(app) }.getOrDefault(emptyList())
main.post { if (seq == listing) items = list }
}, "user-apps-list").start()
}
fun add(context: Context, request: UserApps.Request, label: String) =
run(context, label, R.string.user_apps_added) { app, progress -> UserApps.add(app, request, progress) }
fun remove(context: Context, target: UserApps.App) {
updates = updates - target.key
run(context, target.name, R.string.user_apps_removed) { app, progress -> UserApps.remove(app, target, progress) }
}
fun edit(context: Context, target: UserApps.App, name: String, icon: String?) =
run(context, name, R.string.user_apps_saved) { app, _ -> UserApps.edit(app, target, name, icon) }
fun checkUpdate(context: Context, target: UserApps.App) {
if (checking != null) return
val app = context.applicationContext
checking = target.key
updates = updates - target.key
Thread({
val result = try {
UserApps.checkUpdate(app, target)
} catch (e: Exception) {
UserApps.UpdateCheck.Failed(e.message ?: e.toString())
}
main.post { checking = null; updates = updates + (target.key to result) }
}, "user-apps-check").start()
}
fun update(context: Context, target: UserApps.App, release: UserApps.Release) =
run(context, target.name, R.string.user_apps_updated) { app, progress ->
UserApps.update(app, target, release, progress).also { if (it == null) main.post { updates = updates - target.key } }
}
private fun run(context: Context, label: String, done: Int, work: (Context, (String, Int) -> Unit) -> String?) {
if (working != null) return
val app = context.applicationContext
working = label; stage = null; percent = -1; lastError = null
Thread({
val problem = try {
work(app) { s, p -> main.post { stage = s; percent = p } }
} catch (e: Exception) {
e.message ?: e.toString()
}
main.post {
working = null; stage = null; percent = -1
lastError = problem?.let { "$label: $it" }
if (problem == null) Toast.makeText(app, app.getString(done, label), Toast.LENGTH_SHORT).show()
refresh(app)
StoreState.refresh(app)
}
}, "user-apps-work").start()
}
}
@@ -0,0 +1,535 @@
package com.droiddeck.launcher.ui
import android.app.Activity
import android.view.View
import android.view.inputmethod.InputMethodManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.expandHorizontally
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.shrinkHorizontally
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
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.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
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.Terminal
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
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.draw.drawBehind
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.InputMode
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInputModeManager
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
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 androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.files.InAppFilePicker
import com.droiddeck.launcher.runtime.UserApps
import com.droiddeck.launcher.store.UserAppsState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import java.io.File
private enum class AddSource(val label: Int, val hint: Int) {
SCRIPT(R.string.add_app_tab_script, R.string.add_app_script_hint),
APPIMAGE(R.string.add_app_tab_appimage, R.string.add_app_appimage_hint),
GITHUB(R.string.add_app_tab_github, R.string.add_app_github_hint),
FLATPAK(R.string.add_app_tab_flatpak, R.string.add_app_flatpak_hint),
}
private val ICON_TYPES = listOf("png", "jpg", "jpeg", "webp")
private val FieldShape = RoundedCornerShape(8.dp)
/** What an icon choice shows: a picked file, or a suggestion's link. */
private fun iconModel(ref: String): Any = if (ref.startsWith("https://")) ref else File(ref)
/** The Desktop page's Add: a script, an AppImage, a GitHub release or a Flatpak, with a name and icon. */
@Composable
internal fun AddAppDialog(runtimeReady: Boolean, onDismiss: () -> Unit, onAdd: (UserApps.Request, String) -> Unit) {
val ctx = LocalContext.current
val shown = rememberShown(onDismiss)
val close = { shown.targetState = false }
var source by rememberSaveable { mutableStateOf(AddSource.SCRIPT) }
var scriptPath by rememberSaveable { mutableStateOf<String?>(null) }
var imagePath by rememberSaveable { mutableStateOf<String?>(null) }
var repo by rememberSaveable { mutableStateOf("") }
var flatpak by rememberSaveable { mutableStateOf("") }
var name by rememberSaveable { mutableStateOf("") }
var iconPath by rememberSaveable { mutableStateOf<String?>(null) }
val pickScript = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
if (r.resultCode == Activity.RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { scriptPath = it.path }
}
val pickImage = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
if (r.resultCode == Activity.RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { imagePath = it.path }
}
val pickIcon = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
if (r.resultCode == Activity.RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { iconPath = it.path }
}
val repoId = UserApps.githubRepo(repo)
val flatpakId = UserApps.flatpakId(flatpak)
val custom = name.trim().takeIf { it.isNotEmpty() }
val request: Pair<UserApps.Request, String>? = when (source) {
AddSource.SCRIPT -> scriptPath?.takeIf { it.endsWith(".sh", ignoreCase = true) }?.let { File(it) }
?.let { UserApps.Request(UserApps.Source.Script(it), custom, iconPath) to (custom ?: it.nameWithoutExtension) }
AddSource.APPIMAGE -> imagePath?.takeIf { it.endsWith(".appimage", ignoreCase = true) }?.let { File(it) }
?.let { UserApps.Request(UserApps.Source.AppImage(it), custom, iconPath) to (custom ?: it.nameWithoutExtension) }
AddSource.GITHUB -> repoId?.let { UserApps.Request(UserApps.Source.GitHub(it), custom, iconPath) to (custom ?: it) }
AddSource.FLATPAK -> flatpakId?.let { UserApps.Request(UserApps.Source.Flatpak(it), custom, iconPath) to (custom ?: it) }
}
val busy = UserAppsState.working
val tabFocus = remember { AddSource.entries.map { FocusRequester() } }
val inputMode = LocalInputModeManager.current.inputMode
// Once, as it opens: typing a name switches the input mode too, and must keep its field.
LaunchedEffect(Unit) {
if (inputMode == InputMode.Keyboard) {
repeat(2) { androidx.compose.runtime.withFrameNanos { } }
runCatching { tabFocus[source.ordinal].requestFocus() }
}
}
val step = { by: Int -> source = AddSource.entries[(source.ordinal + by + AddSource.entries.size) % AddSource.entries.size] }
AppDialog(shown, close, "addApp", wide = true, modifier = Modifier.bumpers(onPrevious = { step(-1) }, onNext = { step(1) })) {
DialogHeader(stringResource(R.string.user_apps_back), stringResource(R.string.add_app_title)) {
TabStrip(AddSource.entries.map { stringResource(it.label) }, source.ordinal, { source = AddSource.entries[it] }, focusRequesters = tabFocus)
}
val glyph = if (source == AddSource.SCRIPT) Icons.Outlined.Terminal else Icons.Outlined.Apps
Columns(
first = {
Panel(stringResource(R.string.add_app_source)) {
AnimatedContent(
targetState = source,
transitionSpec = {
val dir = if (targetState.ordinal > initialState.ordinal) 1 else -1
(fadeIn(Motion.tw(260, 60)) + slideInHorizontally(Motion.tw(320, 60)) { dir * it / 12 })
.togetherWith(fadeOut(Motion.tw(140)) + slideOutHorizontally(Motion.tw(140)) { -dir * it / 16 })
},
label = "addSource",
) { src ->
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Small(stringResource(src.hint))
when (src) {
AddSource.SCRIPT -> {
FileRow(scriptPath) { pickScript.launch(InAppFilePicker.buildIntent(ctx, listOf("sh"), ctx.getString(R.string.add_app_pick_script))) }
scriptPath?.let { FolderNote(it) }
}
AddSource.APPIMAGE -> FileRow(imagePath) {
pickImage.launch(InAppFilePicker.buildIntent(ctx, listOf("appimage"), ctx.getString(R.string.add_app_pick_appimage)))
}
AddSource.GITHUB -> CheckedField(
repo, { repo = it.take(200) }, stringResource(R.string.add_app_repository),
stringResource(R.string.add_app_repository_placeholder),
stringResource(R.string.add_app_repository_invalid).takeIf { repo.isNotBlank() && repoId == null },
)
AddSource.FLATPAK -> CheckedField(
flatpak, { flatpak = it.take(200) }, stringResource(R.string.add_app_flatpak_id),
stringResource(R.string.add_app_flatpak_placeholder),
stringResource(R.string.add_app_flatpak_invalid).takeIf { flatpak.isNotBlank() && flatpakId == null },
)
}
}
}
}
},
second = {
Panel(stringResource(R.string.add_app_appearance)) {
AppPreview(iconPath?.let(::iconModel), glyph, request?.second, stringResource(source.label))
NameField(name, { name = it }, stringResource(R.string.add_app_name_placeholder))
IconActions(
onChoose = { pickIcon.launch(InAppFilePicker.buildIntent(ctx, ICON_TYPES, ctx.getString(R.string.add_app_pick_icon))) },
clear = iconPath?.let { R.string.add_app_clear_icon to { iconPath = null } },
)
if (source == AddSource.GITHUB && repoId != null) IconSuggestions(repoId, iconPath) { iconPath = it }
}
},
)
DialogFooter(
when {
!runtimeReady -> stringResource(R.string.add_app_runtime_required)
busy != null -> stringResource(R.string.add_app_busy, busy)
else -> null
},
stringResource(R.string.add_app_confirm), request != null && runtimeReady && busy == null,
onCancel = close,
) { request?.let { (r, label) -> onAdd(r, label); close() } }
}
}
/**
* An added app's name and icon, changed on its page. [icon]: null keeps the current one, "" goes
* back to the default, else a picked file or a suggestion.
*/
@Composable
internal fun EditAppDialog(app: UserApps.App, onDismiss: () -> Unit, onSave: (String, String?) -> Unit) {
val ctx = LocalContext.current
val shown = rememberShown(onDismiss)
val close = { shown.targetState = false }
var name by rememberSaveable(app.key) { mutableStateOf(app.name) }
var icon by rememberSaveable(app.key) { mutableStateOf<String?>(null) }
val pickIcon = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
if (r.resultCode == Activity.RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { icon = it.path }
}
val preview: Any? = when (val i = icon) {
null -> app.icon
"" -> null
else -> iconModel(i)
}
val busy = UserAppsState.working
val changed = name.trim() != app.name || icon != null
AppDialog(shown, close, "editApp", wide = false) {
DialogHeader(stringResource(app.kind.label()), stringResource(R.string.edit_app_title, app.name))
Rise(1) {
Panel(stringResource(R.string.add_app_appearance)) {
AppPreview(
preview, if (app.kind == UserApps.Kind.SCRIPT) Icons.Outlined.Terminal else Icons.Outlined.Apps,
name.trim().ifEmpty { null }, stringResource(app.kind.label()),
)
NameField(name, { name = it }, app.name)
IconActions(
onChoose = { pickIcon.launch(InAppFilePicker.buildIntent(ctx, ICON_TYPES, ctx.getString(R.string.add_app_pick_icon))) },
clear = if (preview == null) null else R.string.add_app_icon_reset to { icon = "" },
)
app.repo?.let { repo -> IconSuggestions(repo, icon) { icon = it } }
}
}
DialogFooter(
busy?.let { stringResource(R.string.add_app_busy, it) },
stringResource(R.string.edit_app_save), name.isNotBlank() && changed && busy == null,
onCancel = close,
) { onSave(name.trim(), icon); close() }
}
}
/** Open from the first frame; [onDismiss] once the closing animation has run. */
@Composable
private fun rememberShown(onDismiss: () -> Unit): MutableTransitionState<Boolean> {
val shown = remember { MutableTransitionState(false).apply { targetState = true } }
LaunchedEffect(shown.currentState, shown.isIdle) { if (shown.isIdle && !shown.currentState && !shown.targetState) onDismiss() }
return shown
}
/**
* The card both dialogs sit in, sized to fit a landscape handheld without scrolling: it scales in
* like the app's menus, rises above the keyboard, and closes on a tap outside or B.
*/
@Composable
private fun AppDialog(
shown: MutableTransitionState<Boolean>, close: () -> Unit, label: String, wide: Boolean,
modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit,
) {
val pal = LocalPalette.current
Dialog(onDismissRequest = close, properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false)) {
// The page's focus memory stays the page's: nothing in here is a place to come back to.
CompositionLocalProvider(LocalFrontFocus provides null) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize().imePadding()
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = close),
) {
AnimatedVisibility(
visibleState = shown,
enter = fadeIn(Motion.tw(180)) + scaleIn(Motion.sp(0.7f), initialScale = 0.94f),
exit = fadeOut(Motion.tw(140)) + scaleOut(Motion.tw(140), targetScale = 0.96f),
label = label,
) {
Column(
verticalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier
.padding(12.dp)
.widthIn(max = if (wide) 860.dp else 560.dp)
.fillMaxWidth()
.heightIn(max = (LocalConfiguration.current.screenHeightDp - 24).dp)
.shadow(24.dp, Shape16, ambientColor = Color.Black, spotColor = Color.Black)
.clip(Shape16)
.background(pal.surfaceVariant.copy(alpha = 0.97f))
.border(1.dp, pal.signal.copy(alpha = 0.22f), Shape16)
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {}
.controllerBack(close)
.then(modifier)
.verticalScroll(rememberScrollState())
.padding(horizontal = 18.dp, vertical = 16.dp),
content = content,
)
}
}
}
}
}
/** The eyebrow and title, with [trailing] (the tabs) beside them when there is room, else under them. */
@Composable
private fun DialogHeader(eyebrow: String, title: String, trailing: (@Composable () -> Unit)? = null) {
val heading = @Composable {
Column {
Eyebrow(eyebrow)
Text(title, fontSize = 20.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
Rise(0) {
BoxWithConstraints(Modifier.fillMaxWidth()) {
when {
trailing == null -> heading()
maxWidth >= 600.dp -> Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) {
Box(Modifier.weight(1f)) { heading() }
trailing()
}
else -> Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { heading(); trailing() }
}
}
}
}
/** Side by side and of one height on a wide card, stacked on a narrow one. */
@Composable
private fun Columns(first: @Composable ColumnScope.() -> Unit, second: @Composable ColumnScope.() -> Unit) {
Rise(1) {
BoxWithConstraints(Modifier.fillMaxWidth()) {
if (maxWidth >= 600.dp) Row(horizontalArrangement = Arrangement.spacedBy(14.dp), modifier = Modifier.height(IntrinsicSize.Max)) {
Column(Modifier.weight(1.15f).fillMaxHeight(), content = first)
Column(Modifier.weight(1f).fillMaxHeight(), content = second)
} else Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
first()
second()
}
}
}
}
/** A titled inset of the card; it fills the height its column is given. */
@Composable
private fun Panel(title: String, content: @Composable ColumnScope.() -> Unit) {
val colors = MaterialTheme.colorScheme
Column(
verticalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxWidth().fillMaxHeight().clip(Shape14)
.background(colors.surface.copy(alpha = 0.6f)).border(1.dp, LocalPalette.current.line, Shape14).padding(14.dp),
) {
Text(title.uppercase(), fontSize = 11.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.2.sp, color = colors.onSurfaceVariant)
content()
}
}
/** Why the main button is off (or nothing), then Cancel and the main button, at the card's foot. */
@Composable
private fun DialogFooter(note: String?, confirm: String, enabled: Boolean, onCancel: () -> Unit, onConfirm: () -> Unit) {
val lineColor = LocalPalette.current.line
Rise(2) {
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.drawBehind { drawLine(lineColor, Offset(0f, 0f), Offset(size.width, 0f), 1.dp.toPx()) }.padding(top = 14.dp),
) {
Box(Modifier.weight(1f)) { if (note != null) Small(note) }
SecondaryButton(stringResource(R.string.add_app_cancel), onClick = onCancel)
PrimaryButton(confirm, enabled = enabled, onClick = onConfirm)
}
}
}
@Composable
private fun Small(text: String, error: Boolean = false) =
Text(text, fontSize = 12.sp, lineHeight = 16.sp, color = if (error) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant)
private fun hideKeyboard(view: View) {
(view.context.getSystemService(android.content.Context.INPUT_METHOD_SERVICE) as? InputMethodManager)
?.hideSoftInputFromWindow(view.windowToken, 0)
}
/** The app's text field, with what is wrong with its value under it. */
@Composable
private fun CheckedField(value: String, onChange: (String) -> Unit, label: String, placeholder: String, problem: String?) {
val view = LocalView.current
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
AdbTextField(
value, onChange, label, KeyboardType.Uri, ImeAction.Done, placeholder = placeholder, compact = true,
onDone = { hideKeyboard(view) },
)
if (problem != null) Small(problem, error = true)
}
}
@Composable
private fun NameField(name: String, onName: (String) -> Unit, placeholder: String) {
val view = LocalView.current
AdbTextField(
name, { onName(it.take(80)) }, stringResource(R.string.add_app_name), KeyboardType.Text, ImeAction.Done,
placeholder = placeholder, compact = true, onDone = { hideKeyboard(view) },
)
}
/** The app as the Desktop page will show it: its icon, its name ([name] null: not known yet) and kind. */
@Composable
private fun AppPreview(icon: Any?, glyph: ImageVector, name: String?, kind: String) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().clip(Shape12).background(colors.surfaceVariant).border(1.dp, pal.line2, Shape12).padding(10.dp),
) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(48.dp).clip(Shape12).background(colors.surface)) {
AnimatedContent(icon, transitionSpec = { (fadeIn(Motion.tw(260)) + scaleIn(Motion.sp(0.6f), 0.85f)) togetherWith fadeOut(Motion.tw(160)) }, label = "appIcon") { model ->
if (model != null) AsyncImage(model = model, contentDescription = stringResource(R.string.add_app_icon), contentScale = ContentScale.Fit, modifier = Modifier.size(40.dp))
else Icon(glyph, stringResource(R.string.add_app_icon_default), tint = colors.onSurfaceVariant, modifier = Modifier.size(24.dp))
}
}
Column(Modifier.weight(1f)) {
Text(
name ?: stringResource(R.string.add_app_preview_name), fontSize = 15.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis,
color = if (name != null) colors.onBackground else colors.onSurfaceVariant,
)
Text(kind, fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1)
}
}
}
/** The icon's picker and [clear] (a label and what it does). */
@Composable
private fun IconActions(onChoose: () -> Unit, clear: Pair<Int, () -> Unit>?) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
SecondaryButton(stringResource(R.string.add_app_choose_icon), compact = true, onClick = onChoose)
AnimatedVisibility(clear != null, enter = fadeIn(Motion.tw(180)) + expandHorizontally(Motion.tw(220)), exit = fadeOut(Motion.tw(120)) + shrinkHorizontally(Motion.tw(180))) {
clear?.let { (label, action) -> SecondaryButton(stringResource(label), compact = true, onClick = action) }
}
}
}
/** Icons found in [repo], looked up off the main thread once the name has settled; [selected] is ringed. */
@Composable
private fun IconSuggestions(repo: String, selected: String?, onPick: (String) -> Unit) {
val found by produceState<List<String>?>(null, repo) {
value = null
delay(500)
value = withContext(Dispatchers.IO) { runCatching { UserApps.githubIcons(repo) }.getOrDefault(emptyList()) }
}
val list = found
if (list != null && list.isEmpty()) return
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.height(44.dp)) {
Small(stringResource(if (list == null) R.string.add_app_icon_searching else R.string.add_app_icon_suggestions))
if (list != null) Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) {
list.forEachIndexed { i, url -> Rise(i) { SuggestedIcon(url, url == selected) { onPick(url) } } }
}
}
}
@Composable
private fun SuggestedIcon(url: String, selected: Boolean, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
val scale by animateFloatAsState(if (hot || selected) 1.06f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "suggestScale")
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(44.dp).graphicsLayer { scaleX = scale; scaleY = scale }.clip(Shape12).background(colors.surface)
.glideBorder(hot || selected, Shape12, pal.signal, pal.line2)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = onClick)
.controllerConfirm(onClick = onClick),
) { AsyncImage(model = ImageRequest.Builder(LocalContext.current).data(url).crossfade(true).build(), contentDescription = stringResource(R.string.add_app_icon), contentScale = ContentScale.Fit, modifier = Modifier.size(34.dp)) }
}
/** The chosen file, or none yet, laid out like the app's text fields, with the button that picks it. */
@Composable
private fun FileRow(path: String?, onChoose: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
Column {
Text(stringResource(R.string.add_app_file), style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Medium, color = colors.onSurfaceVariant)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Box(
contentAlignment = Alignment.CenterStart,
modifier = Modifier.weight(1f).height(44.dp).background(colors.surfaceVariant, FieldShape).border(1.dp, pal.line2, FieldShape).padding(horizontal = 12.dp),
) {
Text(
path?.substringAfterLast('/') ?: stringResource(R.string.add_app_no_file), fontSize = 13.sp, maxLines = 1, overflow = TextOverflow.Ellipsis,
color = if (path != null) colors.onBackground else colors.onSurfaceVariant,
)
}
SecondaryButton(stringResource(if (path == null) R.string.add_app_choose_file else R.string.add_app_change_file), compact = true, onClick = onChoose)
}
}
}
/** Whether the script's folder will be linked or copied in, worked out off the main thread. */
@Composable
private fun FolderNote(scriptPath: String) {
var plan by remember(scriptPath) { mutableStateOf<UserApps.ScriptPlan?>(null) }
LaunchedEffect(scriptPath) {
plan = withContext(Dispatchers.IO) { runCatching { UserApps.planScript(File(scriptPath)) }.getOrNull() }
}
val p = plan
Small(
when {
p == null -> stringResource(R.string.add_app_checking_folder)
p.copy -> stringResource(R.string.add_app_folder_copied, FileUtils.sizeToString(p.bytes))
else -> stringResource(R.string.add_app_folder_linked, p.folder.path)
},
)
}
@@ -26,6 +26,7 @@ import androidx.compose.runtime.key
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
@@ -39,6 +40,14 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.DesktopWindows
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.outlined.Apps
import androidx.compose.material.icons.outlined.Terminal
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
@@ -68,7 +77,10 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.droiddeck.launcher.HomeApp
import com.droiddeck.launcher.R
import com.droiddeck.launcher.frontend.Library
import com.droiddeck.launcher.runtime.UserApps
import com.droiddeck.launcher.store.UserAppsState
import java.io.File
// The front end's right-hand pane and the page it shows: backdrop, desktop card and emulators.
@@ -191,22 +203,37 @@ private fun Content(
selected == "desktop" -> {
val installed = s.emulators.filter { it.installed }
val available = s.emulators.filter { !it.installed }
val ctx = LocalContext.current
LaunchedEffect(Unit) { UserAppsState.refresh(ctx) }
val apps = UserAppsState.items
var adding by rememberSaveable { mutableStateOf(false) }
Rise(0) {
PageHeader("Desktop") {
if (s.desktopInstalled) Chip("● Desktop installed", ok = true) else Chip("Installs on first open", ok = false)
}
}
Rise(2) { DesktopCard(s, a) }
if (installed.isNotEmpty()) {
Rise(3) { SectionTitle("Emulators", "${installed.size} installed") }
Rise(4) { EmulatorGrid(installed, first = true, onSelect = onSelect) }
// Add closes the last grid: the installable emulators, or the installed ones once none are left.
val ready = installed.map { GridItem.Emu(it) } + apps.map { GridItem.User(it) }
val addAfterReady = available.isEmpty()
if (ready.isNotEmpty() || addAfterReady) {
Rise(3) { SectionTitle(stringResource(R.string.user_apps_section), ready.size.takeIf { it > 0 }?.toString()) }
Rise(4) {
LauncherGrid(if (addAfterReady) ready + GridItem.Add else ready, first = true, onSelect = onSelect, onAdd = { adding = true })
}
}
if (s.storeEnabled) Rise(5) { InstalledAppsGrid(a) }
if (s.appImagesEnabled) Rise(5) { AppImagesSection(a, s.ready) }
if (available.isNotEmpty()) {
if (UserAppsState.working != null || UserAppsState.lastError != null) Rise(5) { UserAppsProgress() }
if (!addAfterReady) {
Rise(5) { SectionTitle("Available to install", available.size.toString()) }
Rise(6) { EmulatorGrid(available, first = installed.isEmpty(), onSelect = onSelect) }
Rise(6) {
LauncherGrid(available.map { GridItem.Emu(it) } + GridItem.Add, first = ready.isEmpty(), onSelect = onSelect, onAdd = { adding = true })
}
}
if (adding) AddAppDialog(s.ready, onDismiss = { adding = false }) { request, label -> UserAppsState.add(ctx, request, label) }
}
selected.startsWith("user:") -> {
val app = UserAppsState.items.firstOrNull { "user:${it.key}" == selected }
if (app == null) Note(stringResource(R.string.user_apps_gone)) else UserAppPage(app, s, a, onSelect)
}
selected.startsWith("emu:") -> {
val e = s.emulators.firstOrNull { "emu:${it.id}" == selected }
@@ -365,16 +392,29 @@ private fun DesktopCard(s: FrontEndState, a: FrontEndActions) {
}
}
/** Emulators as wide tiles - three across, or a list on a narrow page. */
/** What the Desktop page's grids hold: emulators, the user's own apps, and Add at the end. */
private sealed class GridItem(val key: String) {
class Emu(val e: Library.Emulator) : GridItem("emu:${e.id}")
class User(val app: UserApps.App) : GridItem("user:${app.key}")
data object Add : GridItem("add")
}
/** Wide tiles - three across, or a list on a narrow page. */
@Composable
private fun EmulatorGrid(emulators: List<Library.Emulator>, first: Boolean, onSelect: (String) -> Unit) {
private fun LauncherGrid(items: List<GridItem>, first: Boolean, onSelect: (String) -> Unit, onAdd: () -> 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)) {
emulators.chunked(columns).forEachIndexed { r, row ->
items.chunked(columns).forEachIndexed { r, row ->
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)) {
row.forEachIndexed { i, e ->
key(e.id) {
EmulatorTile(e, Modifier.weight(1f).fillMaxHeight(), isFirst = first && r == 0 && i == 0) { onSelect("emu:${e.id}") }
row.forEachIndexed { i, item ->
key(item.key) {
val m = Modifier.weight(1f).fillMaxHeight()
val isFirst = first && r == 0 && i == 0
when (item) {
is GridItem.Emu -> EmulatorTile(item.e, m, isFirst) { onSelect(item.key) }
is GridItem.User -> UserAppTile(item.app, m, isFirst) { onSelect(item.key) }
GridItem.Add -> AddTile(m, isFirst, onAdd)
}
}
}
repeat(columns - row.size) { Spacer(Modifier.weight(1f)) }
@@ -383,6 +423,173 @@ private fun EmulatorGrid(emulators: List<Library.Emulator>, first: Boolean, onSe
}
}
/** A grid tile's frame: the emulator tiles' look, focus glide and press. */
@Composable
private fun TileFrame(id: String, modifier: Modifier, isFirst: Boolean, filled: Boolean, onClick: () -> Unit, content: @Composable RowScope.() -> 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 = "tileScale")
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = modifier.paneItem("tile:$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 if (filled) colors.surface else Color.Transparent)
.glideBorder(hot, Shape14, pal.signal, pal.line)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick)
.padding(horizontal = 12.dp, vertical = 10.dp),
) { content() }
}
@Composable
private fun TileText(title: String, detail: String, modifier: Modifier) {
val colors = MaterialTheme.colorScheme
Column(modifier = modifier) {
Text(title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(detail, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
internal fun UserApps.Kind.label() = when (this) {
UserApps.Kind.SCRIPT -> R.string.user_apps_kind_script
UserApps.Kind.APPIMAGE -> R.string.user_apps_kind_appimage
UserApps.Kind.FLATPAK -> R.string.user_apps_kind_flatpak
}
/** An added app's icon: its own, or the kind's when it has none. */
@Composable
private fun UserAppIcon(app: UserApps.App, size: Int) {
if (app.icon != null) AppIcon(app.icon, app.name, size)
else Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(size.dp).clip(RoundedCornerShape((size / 5).dp)).background(MaterialTheme.colorScheme.surfaceVariant),
) {
Icon(
if (app.kind == UserApps.Kind.SCRIPT) Icons.Outlined.Terminal else Icons.Outlined.Apps, null,
tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size((size / 2).dp),
)
}
}
@Composable
private fun UserAppTile(app: UserApps.App, modifier: Modifier, isFirst: Boolean, onClick: () -> Unit) =
TileFrame("user:${app.key}", modifier, isFirst, filled = true, onClick = onClick) {
UserAppIcon(app, 44)
TileText(app.name, stringResource(app.kind.label()), Modifier.weight(1f))
}
/** The grid's last tile: opens the Add dialog. */
@Composable
private fun AddTile(modifier: Modifier, isFirst: Boolean, onClick: () -> Unit) {
val pal = LocalPalette.current
TileFrame("add", modifier, isFirst, filled = false, onClick = onClick) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(44.dp).clip(Shape12).background(pal.signal.copy(alpha = 0.12f)).border(1.dp, pal.signal.copy(alpha = 0.35f), Shape12),
) { Icon(Icons.Filled.Add, null, tint = pal.signal, modifier = Modifier.size(24.dp)) }
TileText(stringResource(R.string.user_apps_add), stringResource(R.string.user_apps_add_detail), Modifier.weight(1f))
}
}
/** The add or remove under way, or why the last one failed. */
@Composable
private fun UserAppsProgress() {
val colors = MaterialTheme.colorScheme
val working = UserAppsState.working
Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
if (working != null) {
val stage = UserAppsState.stage ?: stringResource(R.string.user_apps_starting)
val percent = UserAppsState.percent
Text(
if (percent >= 0) stringResource(R.string.user_apps_progress_percent, working, stage, percent) else stringResource(R.string.user_apps_progress, working, stage),
fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis,
)
if (percent >= 0) LinearProgressIndicator(progress = { percent / 100f }, modifier = Modifier.fillMaxWidth().height(4.dp))
else LinearProgressIndicator(modifier = Modifier.fillMaxWidth().height(4.dp))
} else UserAppsState.lastError?.let { Note(it) }
}
}
/** An added app's page: open it, change its name and icon, update one added from GitHub, or remove it (pressed twice). */
@Composable
private fun UserAppPage(app: UserApps.App, s: FrontEndState, a: FrontEndActions, onSelect: (String) -> Unit) {
val colors = MaterialTheme.colorScheme
val ctx = LocalContext.current
val narrow = LocalNarrowPane.current
var confirm by remember(app.key) { mutableStateOf(false) }
var editing by rememberSaveable(app.key) { mutableStateOf(false) }
LaunchedEffect(confirm) { if (confirm) { kotlinx.coroutines.delay(4000); confirm = false } }
Rise(0) { BackLink(stringResource(R.string.user_apps_back)) { onSelect("desktop") } }
Rise(1) {
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier.padding(top = 12.dp, bottom = 12.dp),
) {
UserAppIcon(app, 52)
Column {
Text(app.name, fontSize = if (narrow) 22.sp else 26.sp, fontWeight = FontWeight.Bold, color = colors.onBackground)
Text(
app.repo?.let { stringResource(R.string.user_apps_github_version, it, app.version.orEmpty()) } ?: stringResource(app.kind.label()),
fontSize = 14.sp, color = colors.onSurfaceVariant,
)
}
}
}
Rise(3) {
Actions {
PrimaryButton(stringResource(R.string.user_apps_open, app.name), enabled = !s.busy, main = true) { a.onUserApp(app) }
BusyChip(s)
SecondaryButton(stringResource(R.string.user_apps_edit), enabled = UserAppsState.working == null) { editing = true }
if (app.repo != null) UpdateButton(app, s)
SecondaryButton(
stringResource(if (confirm) R.string.user_apps_remove_confirm else R.string.user_apps_remove),
enabled = !s.sessionRunning && UserAppsState.working == null,
) {
if (!confirm) confirm = true
else { confirm = false; UserAppsState.remove(ctx, app); onSelect("desktop") }
}
if (s.sessionRunning) ActionChip(stringResource(R.string.user_apps_stop_session), ok = false)
}
}
if (UserAppsState.working != null || UserAppsState.lastError != null) Rise(4) { UserAppsProgress() }
if (editing) EditAppDialog(app, onDismiss = { editing = false }) { name, icon -> UserAppsState.edit(ctx, app, name, icon) }
val detail = app.detail
if (detail != null) Rise(4) {
Box(Modifier.fillMaxWidth().padding(vertical = 8.dp)) {
Note(
if (app.kind != UserApps.Kind.SCRIPT) detail
else stringResource(if (app.copied) R.string.user_apps_script_copied else R.string.user_apps_script_linked, detail),
)
}
}
}
/** Looks up a newer release of an app added from GitHub, then installs it. */
@Composable
private fun UpdateButton(app: UserApps.App, s: FrontEndState) {
val ctx = LocalContext.current
val idle = UserAppsState.working == null && UserAppsState.checking == null
when (val found = UserAppsState.updates[app.key]) {
is UserApps.UpdateCheck.Available -> SecondaryButton(
stringResource(R.string.user_apps_update_to, found.release.tag), enabled = idle && !s.sessionRunning,
) { UserAppsState.update(ctx, app, found.release) }
else -> {
SecondaryButton(
stringResource(if (UserAppsState.checking == app.key) R.string.user_apps_checking_updates else R.string.user_apps_check_updates),
enabled = idle,
) { UserAppsState.checkUpdate(ctx, app) }
when (found) {
is UserApps.UpdateCheck.Current -> ActionChip(stringResource(R.string.user_apps_up_to_date, found.tag), ok = true)
is UserApps.UpdateCheck.Failed -> ActionChip(found.message, ok = false)
else -> {}
}
}
}
}
/**
* One emulator: its icon, system and games. One not yet installed says so in words and keeps full
* contrast - dimming it read as disabled rather than one press from installing.
@@ -391,22 +598,9 @@ private fun EmulatorGrid(emulators: List<Library.Emulator>, first: Boolean, onSe
private fun EmulatorTile(e: Library.Emulator, 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 = "emuScale")
val system = e.system.replaceFirstChar { it.uppercase() }
val detail = if (e.installed && e.id != "retroarch") "$system · ${e.games.size} game${if (e.games.size == 1) "" else "s"}" else system
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = modifier.paneItem("tile:emu:${e.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 if (e.installed) colors.surface else Color.Transparent)
.glideBorder(hot, Shape14, pal.signal, pal.line)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick)
.padding(horizontal = 12.dp, vertical = 10.dp),
) {
TileFrame("emu:${e.id}", modifier, isFirst, filled = e.installed, onClick = onClick) {
Image(painterResource(e.iconRes), contentDescription = null, modifier = Modifier.size(if (e.installed) 44.dp else 36.dp))
Column(modifier = Modifier.weight(1f)) {
Text(e.name, fontSize = if (e.installed) 15.sp else 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
@@ -114,9 +114,8 @@ 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. */
/** The Flathub Store, a beta the user turns on in Setup. */
val storeEnabled: Boolean = false,
val appImagesEnabled: Boolean = false,
/** The Updates page: DroidDeck's own builds and the channel followed. */
val updates: UpdatesState = UpdatesState(),
)
@@ -129,9 +128,8 @@ class FrontEndActions(
val onEmulator: (Library.Emulator) -> Unit,
/** A Flatpak app by id and name, from the Store. */
val onFlatpakApp: (String, String) -> Unit = { _, _ -> },
/** Pick an AppImage to import; open an imported one by its directory and name. */
val onImportAppImage: () -> Unit = {},
val onAppImage: (String, String) -> Unit = { _, _ -> },
/** An app added on the Desktop page. */
val onUserApp: (com.droiddeck.launcher.runtime.UserApps.App) -> Unit = {},
val onRom: (Library.Rom) -> Unit,
val onResume: () -> Unit,
val onSteamSettings: () -> Unit,
@@ -159,7 +157,6 @@ class FrontEndActions(
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 = { _, _ -> },
@@ -357,13 +354,13 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
}
}
BackHandler(enabled = !processSettingsPageVisible && s.pageKey != null && page != null) { a.onPageBack() }
// Back (and B) from a ROM or an emulator steps out one level, as its "‹" link does, instead
// of leaving the app: a ROM -> its emulator, an emulator -> Desktop.
// Back (and B) from a ROM, an emulator or an added app steps out one level, as its "‹" link
// does, instead of leaving the app: a ROM -> its emulator, the others -> Desktop.
BackHandler(
enabled = !processSettingsPageVisible && (s.pageKey == null || page == null) &&
(selected.startsWith("emu:") || selected.startsWith("rom:")),
(selected.startsWith("emu:") || selected.startsWith("rom:") || selected.startsWith("user:")),
) {
selected = if (selected.startsWith("emu:")) "desktop" else "emu:" + selected.removePrefix("rom:").substringBefore(':')
selected = if (selected.startsWith("rom:")) "emu:" + selected.removePrefix("rom:").substringBefore(':') else "desktop"
}
LaunchedEffect(s.isHomeApp) { if (!s.isHomeApp && selected == "android-apps") selected = "steam" }
// The Store turned off in Setup takes its page with it.
@@ -379,7 +376,7 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
s.pageKey?.startsWith("settings:steam") == true -> "steam"
s.pageKey?.startsWith("settings:") == true -> "desktop"
selected.startsWith("app:") -> "games"
selected.startsWith("emu:") || selected.startsWith("rom:") -> "desktop"
selected.startsWith("emu:") || selected.startsWith("rom:") || selected.startsWith("user:") -> "desktop"
else -> s.pageKey ?: selected
}
// Bumped each time a rail item is picked, so a controller moves on into the new page.
@@ -530,7 +527,7 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
}
val hasBackTarget = (s.pageKey != null && page != null) ||
((s.pageKey == null || page == null) &&
(selected.startsWith("emu:") || selected.startsWith("rom:")))
(selected.startsWith("emu:") || selected.startsWith("rom:") || selected.startsWith("user:")))
// At the top of a section, Back goes to the rail - the launcher itself is never backed out of.
BackHandler(enabled = !processSettingsPageVisible && !hasBackTarget) {
if (inputModeManager.inputMode != InputMode.Keyboard) inputModeManager.requestInputMode(InputMode.Keyboard)
@@ -333,12 +333,6 @@ internal fun SetupPanel(
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) }
}
}
}
@@ -178,6 +178,8 @@ internal fun PrimaryButton(
.clip(Shape12)
.background(if (enabled) Brush.linearGradient(listOf(colors.primary, pal.primary2)) else Brush.linearGradient(listOf(colors.surfaceVariant, colors.surfaceVariant)))
.shine(hot, 0.45f)
// Off, its fill can match the surface it sits on: the outline keeps it a button.
.then(if (enabled) Modifier else Modifier.border(1.dp, pal.line2, Shape12))
// The grow and shine alone barely show on the light fill: outline it when a
// controller is on it, as the other controls are.
.glideBorder(hot, Shape12, pal.signal)
@@ -428,7 +428,7 @@ private fun AppTile(app: FlathubApi.AppSummary, modifier: Modifier, isFirst: Boo
/** 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) {
internal 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))
@@ -455,102 +455,3 @@ private fun PillButton(label: String, selected: Boolean, onClick: () -> Unit) {
.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)
.glideBorder(hot, Shape14, pal.signal, pal.line)
.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)
.glideBorder(hot, Shape14, pal.signal)
.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
}
}
}
}
}
}
+82
View File
@@ -108,4 +108,86 @@
<string name="fex_denuvo_detail">Performance with full code checks and the hypervisor bit hidden.</string>
<string name="game_env_custom_value">Custom value</string>
<string name="game_env_select_value">Select value</string>
<string name="user_apps_section">Emulators &amp; apps</string>
<string name="user_apps_add">Add</string>
<string name="user_apps_add_detail">Your own app or game</string>
<string name="user_apps_kind_script">Script</string>
<string name="user_apps_kind_appimage">AppImage</string>
<string name="user_apps_kind_flatpak">Flatpak</string>
<string name="user_apps_progress">%1$s · %2$s</string>
<string name="user_apps_progress_percent">%1$s · %2$s · %3$d%%</string>
<string name="user_apps_starting">Starting…</string>
<string name="user_apps_added">%1$s added</string>
<string name="user_apps_removed">%1$s removed</string>
<string name="user_apps_back">Desktop</string>
<string name="user_apps_gone">That app is gone. Pick it again from Desktop.</string>
<string name="user_apps_open">Open %1$s</string>
<string name="user_apps_remove">Remove</string>
<string name="user_apps_remove_confirm">Press again to remove</string>
<string name="user_apps_stop_session">Stop the session to remove</string>
<string name="user_apps_script_linked">Runs %1$s from its folder, linked into the session where it lies. Removing the app leaves that folder alone.</string>
<string name="user_apps_script_copied">Runs %1$s from a copy of its folder inside DroidDeck. Removing the app deletes the copy, not the original.</string>
<string name="user_apps_runtime_required">Install the Linux runtime first</string>
<string name="user_apps_file_gone">The file is gone</string>
<string name="user_apps_no_space">Not enough free space: copying the folder needs %1$s</string>
<string name="user_apps_copying">Copying %1$s</string>
<string name="user_apps_failed">Adding the app failed</string>
<string name="user_apps_github_checking">Looking for a release of %1$s</string>
<string name="user_apps_github_unreachable">Could not read the releases of %1$s</string>
<string name="user_apps_github_no_appimage">No release of %1$s has an ARM64 AppImage</string>
<string name="user_apps_downloading">Downloading %1$s</string>
<string name="user_apps_download_failed">Download failed</string>
<string name="user_apps_update_failed">Updating the app failed</string>
<string name="user_apps_icon_failed">That icon could not be read</string>
<string name="user_apps_saved">%1$s saved</string>
<string name="user_apps_edit">Edit</string>
<string name="user_apps_check_updates">Check for updates</string>
<string name="user_apps_checking_updates">Checking for updates…</string>
<string name="user_apps_update_to">Update to %1$s</string>
<string name="user_apps_up_to_date">Up to date · %1$s</string>
<string name="user_apps_updated">%1$s updated</string>
<string name="user_apps_github_version">%1$s · %2$s</string>
<string name="add_app_title">Add an app</string>
<string name="add_app_tab_script">Script</string>
<string name="add_app_tab_appimage">AppImage</string>
<string name="add_app_tab_github">GitHub</string>
<string name="add_app_tab_flatpak">Flatpak</string>
<string name="add_app_script_hint">A .sh file. It runs from its own folder, full screen under gamescope.</string>
<string name="add_app_appimage_hint">An ARM64 (aarch64) AppImage. It is unpacked into the Linux runtime once.</string>
<string name="add_app_github_hint">Installs the ARM64 AppImage from the repository\'s newest release.</string>
<string name="add_app_flatpak_hint">A Flathub app ID or its Flathub page. Flatpak is set up first if it is not yet.</string>
<string name="add_app_file">File</string>
<string name="add_app_no_file">No file chosen</string>
<string name="add_app_choose_file">Choose file</string>
<string name="add_app_change_file">Change</string>
<string name="add_app_pick_script">Choose a script</string>
<string name="add_app_pick_appimage">Choose an AppImage</string>
<string name="add_app_pick_icon">Choose an icon</string>
<string name="add_app_repository">Repository</string>
<string name="add_app_repository_placeholder">owner/repo or a github.com link</string>
<string name="add_app_repository_invalid">Not a GitHub repository</string>
<string name="add_app_flatpak_id">App ID</string>
<string name="add_app_flatpak_placeholder">org.ppsspp.PPSSPP</string>
<string name="add_app_flatpak_invalid">Not a Flathub app ID</string>
<string name="add_app_source">Source</string>
<string name="add_app_appearance">On your Desktop</string>
<string name="add_app_preview_name">New app</string>
<string name="add_app_name">Name</string>
<string name="add_app_name_placeholder">From the app</string>
<string name="add_app_icon">Icon</string>
<string name="add_app_icon_default">Default icon</string>
<string name="add_app_choose_icon">Choose icon</string>
<string name="add_app_clear_icon">Clear</string>
<string name="add_app_icon_suggestions">From the repository</string>
<string name="add_app_icon_searching">Looking for icons…</string>
<string name="add_app_icon_reset">Default</string>
<string name="edit_app_title">Edit %1$s</string>
<string name="edit_app_save">Save</string>
<string name="add_app_checking_folder">Checking the folder…</string>
<string name="add_app_folder_linked">Its folder is linked into the session: %1$s</string>
<string name="add_app_folder_copied">Its folder holds Linux programs, which shared storage cannot run, so it is copied into DroidDeck (%1$s).</string>
<string name="add_app_confirm">Add</string>
<string name="add_app_cancel">Cancel</string>
<string name="add_app_runtime_required">Install the Linux runtime from Setup first.</string>
<string name="add_app_busy">Wait for %1$s to finish.</string>
</resources>
@@ -24,6 +24,12 @@ fi
# 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
# sharun images start the app through their own ld.so, patched not to read /etc/ld.so.preload, and
# drop LD_PRELOAD unless allowed: without this the app never gets the session's pads.
if [ -s /etc/ld.so.preload ]; then
preload=$(tr -s '\n' ':' < /etc/ld.so.preload)
export LD_PRELOAD="${preload%:}${LD_PRELOAD:+:$LD_PRELOAD}" SHARUN_ALLOW_LD_PRELOAD=1
fi
flags=()
if [ -e "$APPDIR/chrome-sandbox" ] || compgen -G "$APPDIR/*/chrome-sandbox" >/dev/null; then
flags+=(--no-sandbox)
@@ -1044,7 +1044,56 @@ def retroarch():
print(f"bannerlator-pad-defaults: RetroArch: joypad driver udev -> sdl2 ({cfg})")
for step in (rpcs3, duckstation, pcsx2, armsx2, dolphin, cemu, melonds, ppsspp, retroarch):
# yuzu and its forks: Player 1 in [Controls] of <name>/qt-config.ini, which each writes at its first
# start with the keyboard bound. Bindings are input_common ParamPackages, laid out by position as the
# frontend's own auto-map does (sdl_driver.cpp GetDefaultButtonBinding); the guid is SDL's with the
# name CRC cleared (GetGUID). A key is only read while its \default is false. Done once per emulator,
# and only while Player 1 is still on the keyboard.
SWITCH_PAD = "engine:sdl,port:0,guid:030000005e0400008e02000010010000,"
SWITCH_PLAYER1 = {
"button_a": "button:1", "button_b": "button:0", "button_x": "button:3", "button_y": "button:2",
"button_lstick": "button:9", "button_rstick": "button:10", "button_l": "button:4", "button_r": "button:5",
"button_zl": "axis:2,threshold:0.5,invert:+", "button_zr": "axis:5,threshold:0.5,invert:+",
"button_plus": "button:7", "button_minus": "button:6", "button_home": "button:8",
"button_dleft": "hat:0,direction:left", "button_dup": "hat:0,direction:up",
"button_dright": "hat:0,direction:right", "button_ddown": "hat:0,direction:down",
"lstick": "axis_x:0,axis_y:1,offset_x:0,offset_y:0,invert_x:+,invert_y:+",
"rstick": "axis_x:3,axis_y:4,offset_x:0,offset_y:0,invert_x:+,invert_y:+",
}
def switch_emulators():
for name in sorted(os.listdir(CONFIG)) if os.path.isdir(CONFIG) else ():
cfg = os.path.join(CONFIG, name, "qt-config.ini")
marker = os.path.join(CONFIG, name, ".droiddeck-pad")
if os.path.exists(marker) or not os.path.isfile(cfg):
continue
with open(cfg, encoding="utf-8", errors="replace") as f:
lines = f.read().splitlines()
span = ini_section(lines, "Controls")
if not span:
continue
current = {l.split("=", 1)[0]: l.split("=", 1)[1] for l in lines[span[0]:span[1]] if "=" in l}
if "engine:keyboard" not in current.get("player_0_button_a", ""):
continue
wanted = {"player_0_connected": "true", "player_0_type": "0"}
for key, binding in SWITCH_PLAYER1.items():
wanted[f"player_0_{key}"] = f'"{SWITCH_PAD}{binding}"'
body = [l for l in lines[span[0]:span[1]]
if l.split("=", 1)[0].removesuffix("\\default") not in wanted]
while body and not body[-1].strip():
body.pop()
for key, value in wanted.items():
body += [f"{key}\\default=false", f"{key}={value}"]
lines[span[0]:span[1]] = body + [""]
with open(cfg + ".tmp", "w", encoding="utf-8") as f:
f.write("\n".join(lines) + "\n")
os.replace(cfg + ".tmp", cfg)
open(marker, "w").close()
print(f"bannerlator-pad-defaults: {name}: Player 1 on the controller ({cfg})")
for step in (rpcs3, duckstation, pcsx2, armsx2, dolphin, cemu, melonds, ppsspp, retroarch, switch_emulators):
try:
step()
except Exception as e:
+37
View File
@@ -0,0 +1,37 @@
#!/bin/bash
# A script added on the Desktop page (UserApps), run from its own folder:
# bannerlator-script-run /opt/droiddeck-apps/<id> [args...]
set -u
dir=${1:?usage: bannerlator-script-run <added app directory> [args...]}
shift
entry=$(cat "$dir/entry" 2>/dev/null) || { echo "bannerlator-script-run: $dir has no entry" >&2; exit 127; }
cd -P "$dir/files" 2>/dev/null || { echo "bannerlator-script-run: $dir/files is missing; add the script again" >&2; exit 127; }
folder=$PWD
# Shared storage is noexec, so the script is handed to its interpreter rather than executed.
first=
IFS= read -r first < "$entry"
first=${first%$'\r'}
interp=(/bin/bash)
case $first in '#!'*) read -r -a interp <<< "${first#'#!'}" ;; esac
echo "== script $entry in $folder (${interp[*]})"
case ${DBUS_SESSION_BUS_ADDRESS:-} in
unix:*) "${interp[@]}" "./$entry" "$@" ;;
*) unset DBUS_SESSION_BUS_ADDRESS
if command -v dbus-run-session >/dev/null 2>&1; then
dbus-run-session -- "${interp[@]}" "./$entry" "$@"
else
"${interp[@]}" "./$entry" "$@"
fi ;;
esac
rc=$?
# A launcher script often starts the game and returns; the session lasts while it runs.
running() {
local p exe
for p in /proc/[0-9]*; do
exe=$(readlink "$p/exe" 2>/dev/null) || continue
case $exe in "$folder"/*) return 0 ;; esac
done
return 1
}
while running; do sleep 2; done
exit $rc