diff --git a/app/build.gradle b/app/build.gradle index d2bb5daaf..c63c8e7bf 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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. diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index a2e000aca..68663f806 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -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>(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) diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt b/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt index f4952db75..0407e445b 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt @@ -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() /** diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/AppImageManager.kt b/app/src/main/java/com/droiddeck/launcher/runtime/AppImageManager.kt index a11df690e..7afe4a3bc 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/AppImageManager.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/AppImageManager.kt @@ -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 = 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, 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;" diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/FlatpakManager.kt b/app/src/main/java/com/droiddeck/launcher/runtime/FlatpakManager.kt index 0960d95e9..41a89f4d5 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/FlatpakManager.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/FlatpakManager.kt @@ -89,7 +89,7 @@ object FlatpakManager { /** Runs [argv] in the runtime, its output also in Download/DroidDeck/flatpak-.log. */ private fun runGuest(context: Context, argv: List, 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 exclusive(what: String, block: () -> T): T? { diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt b/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt index 3dd7ec189..a5a653c3e 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt @@ -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/.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, 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, fakeRoot: Boolean, onLine: (String) -> Unit): Int { + private fun runLogged(context: Context, argv: List, 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) } } diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/UserApps.kt b/app/src/main/java/com/droiddeck/launcher/runtime/UserApps.kt new file mode 100644 index 000000000..8f4798ef0 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/runtime/UserApps.kt @@ -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/: 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, 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 { + 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 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 { + 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(" 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? { + 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 { + 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 { + 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> { 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 + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt index f579e6c94..a98a05460 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt @@ -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", diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt index 45dbd421f..6c69bdb7e 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -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 diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt index d622dba45..cd1ef2e9d 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -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() diff --git a/app/src/main/java/com/droiddeck/launcher/store/AppImageState.kt b/app/src/main/java/com/droiddeck/launcher/store/AppImageState.kt deleted file mode 100644 index 91a7ac58c..000000000 --- a/app/src/main/java/com/droiddeck/launcher/store/AppImageState.kt +++ /dev/null @@ -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>(emptyList()) - private set - /** The file being imported and what the import is doing; null when idle. */ - var importing by mutableStateOf(null) - private set - var stage by mutableStateOf(null) - private set - /** Why the last import was refused, shown on the page until the next one. */ - var lastError by mutableStateOf(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() - } -} diff --git a/app/src/main/java/com/droiddeck/launcher/store/UserAppsState.kt b/app/src/main/java/com/droiddeck/launcher/store/UserAppsState.kt new file mode 100644 index 000000000..c47733dd9 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/store/UserAppsState.kt @@ -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>(emptyList()) + private set + /** What is being added or removed; null when idle. */ + var working by mutableStateOf(null) + private set + var stage by mutableStateOf(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(null) + private set + /** The app whose updates are being looked up, and what each lookup found, by app key. */ + var checking by mutableStateOf(null) + private set + var updates by mutableStateOf>(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() + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/AddAppDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/AddAppDialog.kt new file mode 100644 index 000000000..4212dc576 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/AddAppDialog.kt @@ -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(null) } + var imagePath by rememberSaveable { mutableStateOf(null) } + var repo by rememberSaveable { mutableStateOf("") } + var flatpak by rememberSaveable { mutableStateOf("") } + var name by rememberSaveable { mutableStateOf("") } + var iconPath by rememberSaveable { mutableStateOf(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? = 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(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 { + 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, 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 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?>(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(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) + }, + ) +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt index ccfe357ce..b6a7675f3 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt @@ -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, first: Boolean, onSelect: (String) -> Unit) { +private fun LauncherGrid(items: List, 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, 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, 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) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt index e94d9edb9..dccf4fb96 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt @@ -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) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt index e7ab00989..29fbe9abb 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt @@ -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) } } } } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndWidgets.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndWidgets.kt index 8f1f8b3e8..3a4f56de8 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndWidgets.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndWidgets.kt @@ -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) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StorePage.kt b/app/src/main/java/com/droiddeck/launcher/ui/StorePage.kt index 5c6002f68..7ffaf91db 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StorePage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StorePage.kt @@ -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 - } - } - } - } - } -} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cfa4435bd..b6edcf1ab 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -108,4 +108,86 @@ Performance with full code checks and the hypervisor bit hidden. Custom value Select value + Emulators & apps + Add + Your own app or game + Script + AppImage + Flatpak + %1$s · %2$s + %1$s · %2$s · %3$d%% + Starting… + %1$s added + %1$s removed + Desktop + That app is gone. Pick it again from Desktop. + Open %1$s + Remove + Press again to remove + Stop the session to remove + Runs %1$s from its folder, linked into the session where it lies. Removing the app leaves that folder alone. + Runs %1$s from a copy of its folder inside DroidDeck. Removing the app deletes the copy, not the original. + Install the Linux runtime first + The file is gone + Not enough free space: copying the folder needs %1$s + Copying %1$s + Adding the app failed + Looking for a release of %1$s + Could not read the releases of %1$s + No release of %1$s has an ARM64 AppImage + Downloading %1$s + Download failed + Updating the app failed + That icon could not be read + %1$s saved + Edit + Check for updates + Checking for updates… + Update to %1$s + Up to date · %1$s + %1$s updated + %1$s · %2$s + Add an app + Script + AppImage + GitHub + Flatpak + A .sh file. It runs from its own folder, full screen under gamescope. + An ARM64 (aarch64) AppImage. It is unpacked into the Linux runtime once. + Installs the ARM64 AppImage from the repository\'s newest release. + A Flathub app ID or its Flathub page. Flatpak is set up first if it is not yet. + File + No file chosen + Choose file + Change + Choose a script + Choose an AppImage + Choose an icon + Repository + owner/repo or a github.com link + Not a GitHub repository + App ID + org.ppsspp.PPSSPP + Not a Flathub app ID + Source + On your Desktop + New app + Name + From the app + Icon + Default icon + Choose icon + Clear + From the repository + Looking for icons… + Default + Edit %1$s + Save + Checking the folder… + Its folder is linked into the session: %1$s + Its folder holds Linux programs, which shared storage cannot run, so it is copied into DroidDeck (%1$s). + Add + Cancel + Install the Linux runtime from Setup first. + Wait for %1$s to finish. diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-appimage-run b/tools/linuxfs/overlay/usr/local/bin/bannerlator-appimage-run index fbe13caaf..b0cdfc6db 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-appimage-run +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-appimage-run @@ -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) diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-pad-defaults b/tools/linuxfs/overlay/usr/local/bin/bannerlator-pad-defaults index adba13d6a..c1bf90203 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-pad-defaults +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-pad-defaults @@ -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 /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: diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-script-run b/tools/linuxfs/overlay/usr/local/bin/bannerlator-script-run new file mode 100755 index 000000000..56466de89 --- /dev/null +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-script-run @@ -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/ [args...] +set -u +dir=${1:?usage: bannerlator-script-run [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