feat: install Flatpak apps from a .flatpak file (#446)

This commit is contained in:
Kurt Himebauch authored and GitHub committed 2026-10-08 14:13:32 -04:00
1 parent da42a5d7a7
commit 134fa167fe
5 files changed
+102 -22

No files matched your search

@@ -93,9 +93,10 @@ object FlatpakManager {
return GuestCommand.run(context, argv, fakeRoot, name, onLine = onLine)
}
private inline fun <T> exclusive(what: String, block: () -> T): T? {
/** [block]'s result, or [ifBusy]'s while another operation has the installation. */
private inline fun <T> 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<String?, String?> {
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<String>? {
if (!ready(context)) return emptySet()
var apps: Set<String>? = 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") {
@@ -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")
}
}
@@ -124,6 +124,7 @@ internal fun AddAppDialog(runtimeReady: Boolean, onDismiss: () -> Unit, onAdd: (
var imagePath by rememberSaveable { mutableStateOf<String?>(null) }
var repo by rememberSaveable { mutableStateOf("") }
var flatpak by rememberSaveable { mutableStateOf("") }
var bundlePath by rememberSaveable { mutableStateOf<String?>(null) }
var name by rememberSaveable { mutableStateOf("") }
var iconPath by rememberSaveable { mutableStateOf<String?>(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)))
}
}
}
}
}
+2
View File
@@ -186,6 +186,8 @@
<string name="add_app_flatpak_id">App ID</string>
<string name="add_app_flatpak_placeholder">org.ppsspp.PPSSPP</string>
<string name="add_app_flatpak_invalid">Not a Flathub app ID</string>
<string name="add_app_flatpak_bundle_hint">Or a .flatpak file, for an app not on Flathub.</string>
<string name="add_app_pick_flatpak">Choose a .flatpak file</string>
<string name="add_app_source">Source</string>
<string name="add_app_appearance">On your Desktop</string>
<string name="add_app_preview_name">New app</string>
@@ -4,13 +4,14 @@
# Flathub as the remote, this device's architecture.
#
# droiddeck-flatpak install <app-id> the app and whatever runtimes it needs
# droiddeck-flatpak install-bundle <file> a .flatpak file, its runtimes from Flathub
# droiddeck-flatpak uninstall <app-id> the app, then runtimes nothing uses any more
# droiddeck-flatpak update [<app-id>] 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