From 134fa167fea3b3dd8a08152b3507b3afaefdfc35 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:13:32 -0400 Subject: [PATCH] feat: install Flatpak apps from a .flatpak file (#446) --- .../launcher/runtime/FlatpakManager.kt | 27 +++++++++----- .../droiddeck/launcher/runtime/UserApps.kt | 35 ++++++++++++++++-- .../com/droiddeck/launcher/ui/AddAppDialog.kt | 24 +++++++++---- app/src/main/res/values/strings.xml | 2 ++ .../overlay/usr/local/bin/droiddeck-flatpak | 36 ++++++++++++++++--- 5 files changed, 102 insertions(+), 22 deletions(-) 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 bd606f78..8f31e2d2 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/FlatpakManager.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/FlatpakManager.kt @@ -93,9 +93,10 @@ object FlatpakManager { return GuestCommand.run(context, argv, fakeRoot, name, onLine = onLine) } - private inline fun exclusive(what: String, block: () -> T): T? { + /** [block]'s result, or [ifBusy]'s while another operation has the installation. */ + private inline fun exclusive(what: String, ifBusy: () -> T, block: () -> T): T { synchronized(this) { - if (busy != null) return null + if (busy != null) return ifBusy() busy = what } try { return block() } finally { busy = null } @@ -104,7 +105,7 @@ object FlatpakManager { /** Puts Flatpak into the runtime and adds Flathub. Null on success, else what went wrong. */ fun setup(context: Context, onProgress: (String, Int) -> Unit): String? { if (!LinuxRuntime.isInstalled(context)) return context.getString(R.string.user_apps_runtime_required) - return exclusive("setup") { + return exclusive("setup", { context.getString(R.string.flatpakmgr_busy) }) { var failure: String? = null val status = runGuest(context, listOf("/bin/bash", SETUP), fakeRoot = true) { line -> Log.i(TAG, "setup: $line") @@ -119,16 +120,18 @@ object FlatpakManager { !ready(context) -> context.getString(R.string.flatpakmgr_setup_unfinished) else -> null } - } ?: context.getString(R.string.flatpakmgr_busy) + } } /** * One store operation - install, uninstall or update - with its progress as a stage line and * an overall percentage across the steps Flatpak plans (a runtime, its extensions, the app). */ - private fun transaction(context: Context, verb: String, id: String?, onProgress: (String, Int) -> Unit): String? { + private fun transaction( + context: Context, verb: String, id: String?, onProgress: (String, Int) -> Unit, onDone: (JSONObject) -> Unit = {}, + ): String? { if (!ready(context)) return context.getString(R.string.flatpakmgr_not_ready) - return exclusive("$verb:${id ?: "all"}") { + return exclusive("$verb:${id ?: "all"}", { context.getString(R.string.flatpakmgr_busy) }) { var error: String? = null // Progress lines carry no ref; they belong to the step the last "op" line started. var step = context.getString(R.string.flatpakmgr_starting) @@ -141,10 +144,11 @@ object FlatpakManager { "progress" -> onProgress(step, overall(o, o.optInt("percent"))) "error" -> { error = o.optString("message"); Log.w(TAG, "$verb $id: $error") } "warning" -> Log.w(TAG, "$verb $id: ${o.optString("message")}") + "done" -> onDone(o) } } error ?: if (status != 0) context.getString(R.string.flatpakmgr_exit_status, status) else null - } ?: context.getString(R.string.flatpakmgr_busy) + } } private fun stage(context: Context, o: JSONObject): String { @@ -195,13 +199,20 @@ object FlatpakManager { fun install(context: Context, id: String, onProgress: (String, Int) -> Unit) = transaction(context, "install", id, onProgress) fun uninstall(context: Context, id: String, onProgress: (String, Int) -> Unit) = transaction(context, "uninstall", id, onProgress) + + /** Installs the .flatpak bundle at [guestPath], its runtimes from Flathub: the app's ID, and what went wrong if anything. */ + fun installBundle(context: Context, guestPath: String, onProgress: (String, Int) -> Unit): Pair { + var id: String? = null + val problem = transaction(context, "install-bundle", guestPath, onProgress) { id = it.optString("id").ifEmpty { null } } + return id to problem + } fun update(context: Context, id: String?, onProgress: (String, Int) -> Unit) = transaction(context, "update", id, onProgress) /** Installed apps with an update on Flathub, or null when that could not be checked. */ fun updates(context: Context): Set? { if (!ready(context)) return emptySet() var apps: Set? = null - exclusive("updates") { + exclusive("updates", {}) { runGuest(context, listOf("/usr/bin/python3", HELPER, "updates"), fakeRoot = false) { line -> val o = runCatching { JSONObject(line) }.getOrNull() ?: return@runGuest if (o.optString("e") == "updates") { diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/UserApps.kt b/app/src/main/java/com/droiddeck/launcher/runtime/UserApps.kt index 43eb5001..4aee79aa 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/UserApps.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/UserApps.kt @@ -69,6 +69,7 @@ object UserApps { class AppImage(val file: File) : Source() class GitHub(val repo: String) : Source() class Flatpak(val id: String) : Source() + class FlatpakBundle(val file: File) : Source() } /** [icon]: a picked file's path or a suggested icon's URL. */ @@ -124,6 +125,7 @@ object UserApps { 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) + is Source.FlatpakBundle -> addFlatpakBundle(context, s.file, name, icon, onProgress) } } } @@ -422,9 +424,36 @@ object UserApps { 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") + customiseFlatpak(context, id, name, icon) return null } + + private fun addFlatpakBundle(context: Context, file: File, name: String?, icon: File?, onProgress: (String, Int) -> Unit): String? { + if (!file.isFile) return context.getString(R.string.user_apps_file_gone) + if (!FlatpakManager.ready(context)) FlatpakManager.setup(context, onProgress)?.let { return it } + // Shared storage is bound into the runtime at its own path; a file anywhere else is copied in. + val shared = Environment.getExternalStorageDirectory().canonicalPath + val copy = File(LinuxRuntime.rootDir(context), "tmp/droiddeck-bundle.flatpak").takeIf { !file.canonicalPath.startsWith("$shared/") } + try { + if (copy != null) { + onProgress(context.getString(R.string.user_apps_copying, file.name), -1) + file.copyTo(copy.apply { parentFile?.mkdirs() }, overwrite = true) + } + val (id, problem) = FlatpakManager.installBundle(context, copy?.let { "/tmp/${it.name}" } ?: file.canonicalPath, onProgress) + problem?.let { return it } + if (id != null) customiseFlatpak(context, id, name, icon) + return null + } catch (e: Exception) { + Log.e(TAG, "bundle ${file.path}", e) + return e.message ?: context.getString(R.string.user_apps_failed) + } finally { + copy?.delete() + } + } + + private fun customiseFlatpak(context: Context, id: String, name: String?, icon: File?) { + val custom = overrides(context, id) + if (name != null) FileUtils.writeString(File(custom.apply { mkdirs() }, "name"), name) + if (icon != null && !saveIcon(icon, File(custom.apply { mkdirs() }, "icon.png"))) Log.w(TAG, "icon ${icon.path} could not be read") + } } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/AddAppDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/AddAppDialog.kt index 82e6936a..b18aa114 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/AddAppDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/AddAppDialog.kt @@ -124,6 +124,7 @@ internal fun AddAppDialog(runtimeReady: Boolean, onDismiss: () -> Unit, onAdd: ( var imagePath by rememberSaveable { mutableStateOf(null) } var repo by rememberSaveable { mutableStateOf("") } var flatpak by rememberSaveable { mutableStateOf("") } + var bundlePath by rememberSaveable { mutableStateOf(null) } var name by rememberSaveable { mutableStateOf("") } var iconPath by rememberSaveable { mutableStateOf(null) } @@ -133,6 +134,9 @@ internal fun AddAppDialog(runtimeReady: Boolean, onDismiss: () -> Unit, onAdd: ( val pickImage = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { r -> if (r.resultCode == Activity.RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { imagePath = it.path } } + val pickBundle = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { r -> + if (r.resultCode == Activity.RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { bundlePath = it.path; flatpak = "" } + } val pickIcon = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { r -> if (r.resultCode == Activity.RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { iconPath = it.path } } @@ -146,7 +150,9 @@ internal fun AddAppDialog(runtimeReady: Boolean, onDismiss: () -> Unit, onAdd: ( 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) } + AddSource.FLATPAK -> bundlePath?.takeIf { it.endsWith(".flatpak", ignoreCase = true) }?.let { File(it) } + ?.let { UserApps.Request(UserApps.Source.FlatpakBundle(it), custom, iconPath) to (custom ?: it.nameWithoutExtension) } + ?: flatpakId?.let { UserApps.Request(UserApps.Source.Flatpak(it), custom, iconPath) to (custom ?: it) } } val busy = UserAppsState.working val tabFocus = remember { AddSource.entries.map { FocusRequester() } } @@ -192,11 +198,17 @@ internal fun AddAppDialog(runtimeReady: Boolean, onDismiss: () -> Unit, onAdd: ( 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 }, - ) + AddSource.FLATPAK -> { + CheckedField( + flatpak, { flatpak = it.take(200); bundlePath = null }, 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 }, + ) + Small(stringResource(R.string.add_app_flatpak_bundle_hint)) + FileRow(bundlePath) { + pickBundle.launch(InAppFilePicker.buildIntent(ctx, listOf("flatpak"), ctx.getString(R.string.add_app_pick_flatpak))) + } + } } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9390ac24..50f9c637 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -186,6 +186,8 @@ App ID org.ppsspp.PPSSPP Not a Flathub app ID + Or a .flatpak file, for an app not on Flathub. + Choose a .flatpak file Source On your Desktop New app diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-flatpak b/tools/linuxfs/overlay/usr/local/bin/droiddeck-flatpak index 9be0267a..a8226b51 100644 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-flatpak +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-flatpak @@ -4,13 +4,14 @@ # Flathub as the remote, this device's architecture. # # droiddeck-flatpak install the app and whatever runtimes it needs +# droiddeck-flatpak install-bundle a .flatpak file, its runtimes from Flathub # droiddeck-flatpak uninstall the app, then runtimes nothing uses any more # droiddeck-flatpak update [] one app, or everything with an update # droiddeck-flatpak updates what has one (a "updates" line) # # Lines: {"e":"op","i":1,"n":3,"kind":"install","ref":"..."} as each step starts, # {"e":"progress","i":1,"n":3,"percent":45,"status":"..."} while it runs, -# {"e":"error","message":"..."} and {"e":"done"} at the end. Exit status 0 on success. +# {"e":"error","message":"..."} and {"e":"done"} at the end (with "id" for a bundle). Exit status 0 on success. import json import os import sys @@ -20,7 +21,7 @@ os.environ.setdefault("FLATPAK_BWRAP", "/usr/local/bin/droiddeck-bwrap") import gi # noqa: E402 gi.require_version("Flatpak", "1.0") -from gi.repository import Flatpak, GLib # noqa: E402 +from gi.repository import Flatpak, Gio, GLib # noqa: E402 REMOTE = "flathub" @@ -158,6 +159,29 @@ def install(app_id): return rc +def install_bundle(path): + """A single-file bundle (flatpak build-bundle), for apps Flathub does not carry.""" + inst = installation() + bundle = Gio.File.new_for_path(path) + try: + app_id = Flatpak.BundleRef.new(bundle).get_name() + except GLib.Error as err: + emit(e="error", message="%s is not a Flatpak bundle (%s)" % (os.path.basename(path), err.message)) + return 1 + if app_ref(inst, app_id) is not None: + emit(e="done", id=app_id, note="already installed") + return 0 + t, state = transaction(inst) + t.add_install_bundle(bundle, None) + rc = run(t, state, report_done=False) + if rc == 0 and app_ref(installation(), app_id) is None: + emit(e="error", message="%s did not install; see flatpak-install-bundle.log" % app_id) + return 1 + if rc == 0: + emit(e="done", id=app_id) + return rc + + def uninstall(app_id): inst = installation() ref = app_ref(inst, app_id) @@ -216,20 +240,22 @@ def updates(): def main(): if len(sys.argv) < 2: - sys.stderr.write(__doc__ or "usage: droiddeck-flatpak install|uninstall|update|updates [app-id]\n") + sys.stderr.write(__doc__ or "usage: droiddeck-flatpak install|install-bundle|uninstall|update|updates [app-id|file]\n") return 2 cmd, args = sys.argv[1], sys.argv[2:] - if cmd in ("install", "update"): + if cmd in ("install", "install-bundle", "update"): prepare_rootfs() if cmd == "install" and args: return install(args[0]) + if cmd == "install-bundle" and args: + return install_bundle(args[0]) if cmd == "uninstall" and args: return uninstall(args[0]) if cmd == "update": return update(args[0] if args else None) if cmd == "updates": return updates() - sys.stderr.write("usage: droiddeck-flatpak install|uninstall|update|updates [app-id]\n") + sys.stderr.write("usage: droiddeck-flatpak install|install-bundle|uninstall|update|updates [app-id|file]\n") return 2