Skip to content

Commit 0d44525

Browse files
authored
Merge pull request #339 from OpenHub-Store/flatpak-pr
build: refactor Flatpak packaging to use online builds and force X11 …
2 parents f1b9197 + d2e82a6 commit 0d44525

15 files changed

Lines changed: 24079 additions & 3128 deletions

File tree

.claude/settings.local.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"permissions": {
3+
"allow": [
4+
"Bash(grep -r \"\\\\.FLATPAK\\\\|Flatpak\\\\|flatpak\" D:/development/Github-Store --include=*.kt)"
5+
]
6+
}
7+
}

.gitattributes

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Flatpak packaging files must use Unix LF line endings
2+
# (flatpak-builder on Linux can't parse JSON with CRLF)
3+
packaging/flatpak/*.json text eol=lf
4+
packaging/flatpak/*.yml text eol=lf
5+
packaging/flatpak/*.sh text eol=lf

build-logic/convention/build.gradle.kts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
33

44
plugins {
55
`kotlin-dsl`
6+
alias(libs.plugins.flatpak.gradle.generator)
67
}
78

89
group = "zed.rainxch.convention.buildlogic"
@@ -38,6 +39,12 @@ tasks {
3839
}
3940
}
4041

42+
tasks.flatpakGradleGenerator {
43+
outputFile = file("../../packaging/flatpak/flatpak-sources-convention.json")
44+
downloadDirectory.set("./offline-repository")
45+
excludeConfigurations.set(listOf("testCompileClasspath", "testRuntimeClasspath"))
46+
}
47+
4148
gradlePlugin {
4249
plugins {
4350
register("androidApplication") {

build.gradle.kts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
plugins {
2-
id("io.github.jwharm.flatpak-gradle-generator") version "1.7.0"
2+
alias(libs.plugins.flatpak.gradle.generator)
33
alias(libs.plugins.android.application) apply false
44
alias(libs.plugins.android.library) apply false
55
alias(libs.plugins.compose.hot.reload) apply false
@@ -12,8 +12,10 @@ plugins {
1212
alias(libs.plugins.room) apply false
1313
}
1414

15-
tasks.named<io.github.jwharm.flatpakgradlegenerator.FlatpakGradleGeneratorTask>("flatpakGradleGenerator") {
16-
outputFile.set(layout.buildDirectory.file("flatpak-sources.json"))
15+
tasks.flatpakGradleGenerator {
16+
outputFile = file("packaging/flatpak/flatpak-sources.json")
17+
downloadDirectory.set("./offline-repository")
18+
excludeConfigurations.set(listOf("testCompileClasspath", "testRuntimeClasspath"))
1719
}
1820

1921
subprojects {

core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopInstaller.kt

Lines changed: 227 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,19 @@ class DesktopInstaller(
3232
determineSystemArchitecture()
3333
}
3434

35+
/**
36+
* Detects whether the app is running inside a Flatpak sandbox.
37+
* Checks for the `/.flatpak-info` file which is always present inside Flatpak containers.
38+
*/
39+
private val isRunningInFlatpak: Boolean by lazy {
40+
try {
41+
File("/.flatpak-info").exists() ||
42+
System.getenv("FLATPAK_ID") != null
43+
} catch (_: Exception) {
44+
false
45+
}
46+
}
47+
3548
override fun getApkInfoExtractor(): InstallerInfoExtractor = installerInfoExtractor
3649

3750
override fun detectSystemArchitecture(): SystemArchitecture = systemArchitecture
@@ -54,13 +67,11 @@ class DesktopInstaller(
5467
}
5568

5669
override fun openApp(packageName: String): Boolean {
57-
// Desktop apps are launched differently per platform
5870
Logger.d { "Open app not supported on desktop for: $packageName" }
5971
return false
6072
}
6173

6274
override fun openWithExternalInstaller(filePath: String) {
63-
// Not applicable on desktop
6475
}
6576

6677
override fun isAssetInstallable(assetName: String): Boolean {
@@ -108,10 +119,18 @@ class DesktopInstaller(
108119
}
109120

110121
Platform.LINUX -> {
111-
when (linuxPackageType) {
112-
LinuxPackageType.DEB -> listOf(".appimage", ".deb", ".rpm")
113-
LinuxPackageType.RPM -> listOf(".appimage", ".rpm", ".deb")
114-
LinuxPackageType.UNIVERSAL -> listOf(".appimage", ".deb", ".rpm")
122+
if (isRunningInFlatpak) {
123+
when (linuxPackageType) {
124+
LinuxPackageType.DEB -> listOf(".deb", ".appimage", ".rpm")
125+
LinuxPackageType.RPM -> listOf(".rpm", ".appimage", ".deb")
126+
LinuxPackageType.UNIVERSAL -> listOf(".appimage", ".deb", ".rpm")
127+
}
128+
} else {
129+
when (linuxPackageType) {
130+
LinuxPackageType.DEB -> listOf(".appimage", ".deb", ".rpm")
131+
LinuxPackageType.RPM -> listOf(".appimage", ".rpm", ".deb")
132+
LinuxPackageType.UNIVERSAL -> listOf(".appimage", ".deb", ".rpm")
133+
}
115134
}
116135
}
117136
}
@@ -174,6 +193,15 @@ class DesktopInstaller(
174193
private fun determineLinuxPackageType(): LinuxPackageType {
175194
if (platform != Platform.LINUX) return LinuxPackageType.UNIVERSAL
176195

196+
if (isRunningInFlatpak) {
197+
return try {
198+
detectHostLinuxPackageType()
199+
} catch (e: Exception) {
200+
Logger.w { "Failed to detect host Linux package type from Flatpak: ${e.message}" }
201+
LinuxPackageType.UNIVERSAL
202+
}
203+
}
204+
177205
return try {
178206
val osRelease = tryReadOsRelease()
179207
if (osRelease != null) {
@@ -233,6 +261,43 @@ class DesktopInstaller(
233261
}
234262
}
235263

264+
/**
265+
* When running inside a Flatpak sandbox, /etc/os-release belongs to the Flatpak runtime
266+
* (e.g. org.freedesktop.Platform), not the host OS. To detect the host distro we read
267+
* /run/host/os-release, which Flatpak bind-mounts from the host.
268+
*/
269+
private fun detectHostLinuxPackageType(): LinuxPackageType {
270+
val hostOsRelease = File("/run/host/os-release")
271+
if (!hostOsRelease.exists()) {
272+
Logger.w { "Host os-release not available at /run/host/os-release" }
273+
return LinuxPackageType.UNIVERSAL
274+
}
275+
276+
val osRelease = parseOsRelease(hostOsRelease.readText())
277+
val id = osRelease["ID"]?.lowercase() ?: ""
278+
val idLike = osRelease["ID_LIKE"]?.lowercase() ?: ""
279+
280+
Logger.d { "Host distro detected from Flatpak: ID=$id, ID_LIKE=$idLike" }
281+
282+
if (id in listOf("debian", "ubuntu", "linuxmint", "pop", "elementary") ||
283+
idLike.contains("debian") || idLike.contains("ubuntu")
284+
) {
285+
Logger.d { "Host is Debian-based: $id" }
286+
return LinuxPackageType.DEB
287+
}
288+
289+
if (id in listOf("fedora", "rhel", "centos", "rocky", "almalinux", "opensuse", "suse") ||
290+
idLike.contains("fedora") || idLike.contains("rhel") ||
291+
idLike.contains("suse") || idLike.contains("centos")
292+
) {
293+
Logger.d { "Host is RPM-based: $id" }
294+
return LinuxPackageType.RPM
295+
}
296+
297+
Logger.d { "Could not classify host distro, defaulting to UNIVERSAL" }
298+
return LinuxPackageType.UNIVERSAL
299+
}
300+
236301
private fun tryReadOsRelease(): Map<String, String>? {
237302
val osReleaseFiles =
238303
listOf(
@@ -318,6 +383,11 @@ class DesktopInstaller(
318383
withContext(Dispatchers.IO) {
319384
val ext = extOrMime.lowercase().removePrefix(".")
320385

386+
if (isRunningInFlatpak) {
387+
Logger.d { "Running in Flatpak — skipping permission checks for .$ext" }
388+
return@withContext
389+
}
390+
321391
if (platform == Platform.LINUX && ext == "appimage") {
322392
try {
323393
val tempFile = File.createTempFile("appimage_perm_test", ".tmp")
@@ -326,7 +396,7 @@ class DesktopInstaller(
326396
if (!canSetExecutable) {
327397
throw IllegalStateException(
328398
"Unable to set executable permissions. AppImage installation requires " +
329-
"the ability to make files executable.",
399+
"the ability to make files executable.",
330400
)
331401
}
332402
} finally {
@@ -347,7 +417,6 @@ class DesktopInstaller(
347417
}
348418

349419
override fun uninstall(packageName: String) {
350-
// Desktop doesn't have a unified uninstall mechanism
351420
Logger.d { "Uninstall not supported on desktop for: $packageName" }
352421
}
353422

@@ -363,6 +432,11 @@ class DesktopInstaller(
363432

364433
val ext = extOrMime.lowercase().removePrefix(".")
365434

435+
if (isRunningInFlatpak) {
436+
installFromFlatpak(file, ext)
437+
return@withContext InstallOutcome.DELEGATED_TO_SYSTEM
438+
}
439+
366440
when (platform) {
367441
Platform.WINDOWS -> installWindows(file, ext)
368442
Platform.MACOS -> installMacOS(file, ext)
@@ -371,7 +445,145 @@ class DesktopInstaller(
371445
}
372446

373447
InstallOutcome.DELEGATED_TO_SYSTEM
448+
449+
}
450+
451+
/**
452+
* Flatpak-sandboxed installation flow.
453+
*
454+
* Since we can't execute system installers, we use xdg-open (which goes through
455+
* the Flatpak portal to the host) to open the file with the host's default handler.
456+
* This lets the host's software center / file manager handle the actual installation.
457+
*/
458+
private fun installFromFlatpak(
459+
file: File,
460+
ext: String,
461+
) {
462+
Logger.i { "Running in Flatpak sandbox — delegating installation to host system" }
463+
Logger.i { "File: ${file.absolutePath} (.$ext)" }
464+
465+
when (ext) {
466+
"deb", "rpm" -> {
467+
Logger.d { "Opening .$ext package via xdg-open portal for host installation" }
468+
try {
469+
val process = ProcessBuilder("xdg-open", file.absolutePath).start()
470+
val exitCode = process.waitFor()
471+
if (exitCode == 0) {
472+
Logger.i { "Package opened on host system for installation" }
473+
showFlatpakNotification(
474+
title = "Package Ready to Install",
475+
message = "The ${ext.uppercase()} package has been opened in your system's " +
476+
"software installer. Follow the prompts to complete installation.",
477+
)
478+
} else {
479+
Logger.w { "xdg-open exited with code $exitCode" }
480+
showFlatpakNotification(
481+
title = "Installation",
482+
message = "Please open this file with your software center to install.",
483+
)
484+
openInFileManager(file)
485+
}
486+
} catch (e: Exception) {
487+
Logger.w { "Failed to open file via xdg-open: ${e.message}" }
488+
showFlatpakNotification(
489+
title = "Download Complete",
490+
message = "Please install manually from your file manager.",
491+
)
492+
openInFileManager(file)
493+
}
494+
}
495+
496+
"appimage" -> {
497+
Logger.d { "AppImage downloaded in Flatpak — preparing for host launch" }
498+
499+
try {
500+
file.setExecutable(true, false)
501+
Logger.d { "Set executable permission on AppImage" }
502+
} catch (e: Exception) {
503+
Logger.w { "Could not set executable permission: ${e.message}" }
504+
}
505+
506+
showFlatpakNotification(
507+
title = "AppImage Downloaded",
508+
message = "Right-click → Properties → mark as executable, then double-click to run.",
509+
)
510+
511+
openInFileManager(file)
512+
}
513+
514+
else -> {
515+
showFlatpakNotification(
516+
title = "Download Complete",
517+
message = "File saved to your Downloads folder.",
518+
)
519+
openInFileManager(file)
520+
}
374521
}
522+
}
523+
524+
/**
525+
* Show a notification from within the Flatpak sandbox.
526+
* Uses notify-send which goes through the desktop notifications portal.
527+
* Falls back to logging if notifications aren't available.
528+
*/
529+
private fun showFlatpakNotification(
530+
title: String,
531+
message: String,
532+
) {
533+
try {
534+
ProcessBuilder(
535+
"notify-send",
536+
"--app-name=GitHub Store",
537+
title,
538+
message,
539+
"-u",
540+
"normal",
541+
"-t",
542+
"15000",
543+
).start()
544+
} catch (e: Exception) {
545+
Logger.w { "Could not show Flatpak notification: ${e.message}" }
546+
Logger.i { "[$title] $message" }
547+
}
548+
}
549+
550+
/**
551+
* Opens the system file manager with the given file highlighted/selected.
552+
*
553+
* Tries D-Bus FileManager1.ShowItems first (works on GNOME, KDE, etc. and
554+
* goes through the Flatpak portal), then falls back to xdg-open on the
555+
* parent directory.
556+
*/
557+
private fun openInFileManager(file: File) {
558+
try {
559+
val fileUri = "file://${file.absolutePath}"
560+
val process = ProcessBuilder(
561+
"gdbus", "call",
562+
"--session",
563+
"--dest", "org.freedesktop.FileManager1",
564+
"--object-path", "/org/freedesktop/FileManager1",
565+
"--method", "org.freedesktop.FileManager1.ShowItems",
566+
"['$fileUri']", "",
567+
).start()
568+
val exitCode = process.waitFor()
569+
570+
if (exitCode == 0) {
571+
Logger.d { "Opened file manager via D-Bus ShowItems: ${file.absolutePath}" }
572+
return
573+
}
574+
Logger.w { "D-Bus ShowItems failed with exit code $exitCode" }
575+
} catch (e: Exception) {
576+
Logger.w { "D-Bus ShowItems not available: ${e.message}" }
577+
}
578+
579+
try {
580+
val parentDir = file.parentFile ?: return
581+
ProcessBuilder("xdg-open", parentDir.absolutePath).start()
582+
Logger.d { "Opened parent directory: ${parentDir.absolutePath}" }
583+
} catch (e: Exception) {
584+
Logger.w { "Could not open file manager: ${e.message}" }
585+
}
586+
}
375587

376588
private fun installWindows(
377589
file: File,
@@ -507,7 +719,12 @@ class DesktopInstaller(
507719
val installMethods =
508720
listOf(
509721
listOf("pkexec", "apt", "install", "-y", file.absolutePath),
510-
listOf("pkexec", "sh", "-c", "dpkg -i '${file.absolutePath}' || apt-get install -f -y"),
722+
listOf(
723+
"pkexec",
724+
"sh",
725+
"-c",
726+
"dpkg -i '${file.absolutePath}' || apt-get install -f -y"
727+
),
511728
listOf("gdebi-gtk", file.absolutePath),
512729
null,
513730
)
@@ -899,7 +1116,7 @@ class DesktopInstaller(
8991116
e.printStackTrace()
9001117
throw IllegalStateException(
9011118
"Failed to install AppImage: ${e.message}. " +
902-
"Please ensure you have write permissions to ~/Applications folder.",
1119+
"Please ensure you have write permissions to ~/Applications folder.",
9031120
e,
9041121
)
9051122
} catch (e: SecurityException) {

feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1073,6 +1073,7 @@ class DetailsViewModel(
10731073
installOutcome = installOutcome,
10741074
)
10751075
} else if (platform != Platform.ANDROID) {
1076+
cachedDownloadAssetName = null
10761077
viewModelScope.launch {
10771078
_events.send(DetailsEvent.OnMessage(getString(Res.string.installer_saved_downloads)))
10781079
}

0 commit comments

Comments
 (0)