mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-12 03:21:08 +00:00
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:
1 parent
d506394f87
commit
e31801df35
22 files changed
+1589
-265
No files matched your search
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 & 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:
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user