mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-14 11:17:16 -04:00
feat: install Flatpak apps from a .flatpak file (#446)
This commit is contained in:
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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user