Skip to content

Commit 43da234

Browse files
committed
feat(desktop): Add Flatpak sandbox support for Linux installations
- Implement Flatpak detection by checking `/.flatpak-info` and environment variables. - Add host OS detection from within Flatpak by reading `/run/host/os-release`. - Update asset filtering to prefer native packages (`.deb`, `.rpm`) over AppImages when running in Flatpak due to FUSE limitations. - Implement a delegated installation flow using `xdg-open` portals to hand off package installation to the host system. - Add desktop notification support for Flatpak using `notify-send` via the notifications portal. - Skip executable permission checks for Linux downloads when sandboxed.
1 parent 7c807b3 commit 43da234

1 file changed

Lines changed: 211 additions & 4 deletions

File tree

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

Lines changed: 211 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,19 @@ class DesktopInstaller(
3131
determineSystemArchitecture()
3232
}
3333

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

3649
override fun detectSystemArchitecture(): SystemArchitecture = systemArchitecture
@@ -65,6 +78,10 @@ class DesktopInstaller(
6578
override fun isAssetInstallable(assetName: String): Boolean {
6679
val name = assetName.lowercase()
6780

81+
// In Flatpak, only allow downloading — we can't actually install system packages.
82+
// AppImages also can't run inside the sandbox (no FUSE).
83+
// We still mark them as "installable" so the user can download them,
84+
// but installation will hand off to the host system.
6885
val hasValidExtension =
6986
when (platform) {
7087
Platform.ANDROID -> {
@@ -107,10 +124,21 @@ class DesktopInstaller(
107124
}
108125

109126
Platform.LINUX -> {
110-
when (linuxPackageType) {
111-
LinuxPackageType.DEB -> listOf(".appimage", ".deb", ".rpm")
112-
LinuxPackageType.RPM -> listOf(".appimage", ".rpm", ".deb")
113-
LinuxPackageType.UNIVERSAL -> listOf(".appimage", ".deb", ".rpm")
127+
if (isRunningInFlatpak) {
128+
// In Flatpak, prefer native packages over AppImages since AppImages
129+
// need FUSE (unavailable in sandbox). The user will install from
130+
// their file manager on the host, where DEB/RPM work natively.
131+
when (linuxPackageType) {
132+
LinuxPackageType.DEB -> listOf(".deb", ".appimage", ".rpm")
133+
LinuxPackageType.RPM -> listOf(".rpm", ".appimage", ".deb")
134+
LinuxPackageType.UNIVERSAL -> listOf(".appimage", ".deb", ".rpm")
135+
}
136+
} else {
137+
when (linuxPackageType) {
138+
LinuxPackageType.DEB -> listOf(".appimage", ".deb", ".rpm")
139+
LinuxPackageType.RPM -> listOf(".appimage", ".rpm", ".deb")
140+
LinuxPackageType.UNIVERSAL -> listOf(".appimage", ".deb", ".rpm")
141+
}
114142
}
115143
}
116144
}
@@ -173,6 +201,17 @@ class DesktopInstaller(
173201
private fun determineLinuxPackageType(): LinuxPackageType {
174202
if (platform != Platform.LINUX) return LinuxPackageType.UNIVERSAL
175203

204+
// Inside Flatpak, /etc/os-release belongs to the runtime (org.freedesktop.Platform),
205+
// not the host OS. We need to read the host's os-release instead.
206+
if (isRunningInFlatpak) {
207+
return try {
208+
detectHostLinuxPackageType()
209+
} catch (e: Exception) {
210+
Logger.w { "Failed to detect host Linux package type from Flatpak: ${e.message}" }
211+
LinuxPackageType.UNIVERSAL
212+
}
213+
}
214+
176215
return try {
177216
val osRelease = tryReadOsRelease()
178217
if (osRelease != null) {
@@ -232,6 +271,43 @@ class DesktopInstaller(
232271
}
233272
}
234273

274+
/**
275+
* When running inside a Flatpak sandbox, /etc/os-release belongs to the Flatpak runtime
276+
* (e.g. org.freedesktop.Platform), not the host OS. To detect the host distro we read
277+
* /run/host/os-release, which Flatpak bind-mounts from the host.
278+
*/
279+
private fun detectHostLinuxPackageType(): LinuxPackageType {
280+
val hostOsRelease = File("/run/host/os-release")
281+
if (!hostOsRelease.exists()) {
282+
Logger.w { "Host os-release not available at /run/host/os-release" }
283+
return LinuxPackageType.UNIVERSAL
284+
}
285+
286+
val osRelease = parseOsRelease(hostOsRelease.readText())
287+
val id = osRelease["ID"]?.lowercase() ?: ""
288+
val idLike = osRelease["ID_LIKE"]?.lowercase() ?: ""
289+
290+
Logger.d { "Host distro detected from Flatpak: ID=$id, ID_LIKE=$idLike" }
291+
292+
if (id in listOf("debian", "ubuntu", "linuxmint", "pop", "elementary") ||
293+
idLike.contains("debian") || idLike.contains("ubuntu")
294+
) {
295+
Logger.d { "Host is Debian-based: $id" }
296+
return LinuxPackageType.DEB
297+
}
298+
299+
if (id in listOf("fedora", "rhel", "centos", "rocky", "almalinux", "opensuse", "suse") ||
300+
idLike.contains("fedora") || idLike.contains("rhel") ||
301+
idLike.contains("suse") || idLike.contains("centos")
302+
) {
303+
Logger.d { "Host is RPM-based: $id" }
304+
return LinuxPackageType.RPM
305+
}
306+
307+
Logger.d { "Could not classify host distro, defaulting to UNIVERSAL" }
308+
return LinuxPackageType.UNIVERSAL
309+
}
310+
235311
private fun tryReadOsRelease(): Map<String, String>? {
236312
val osReleaseFiles =
237313
listOf(
@@ -317,6 +393,14 @@ class DesktopInstaller(
317393
withContext(Dispatchers.IO) {
318394
val ext = extOrMime.lowercase().removePrefix(".")
319395

396+
// In Flatpak we don't need to check executable permissions — we won't be
397+
// running anything ourselves. The file is downloaded to xdg-download and
398+
// the user installs from their host file manager.
399+
if (isRunningInFlatpak) {
400+
Logger.d { "Running in Flatpak — skipping permission checks for .$ext" }
401+
return@withContext
402+
}
403+
320404
if (platform == Platform.LINUX && ext == "appimage") {
321405
try {
322406
val tempFile = File.createTempFile("appimage_perm_test", ".tmp")
@@ -361,6 +445,18 @@ class DesktopInstaller(
361445

362446
val ext = extOrMime.lowercase().removePrefix(".")
363447

448+
// Inside the Flatpak sandbox we cannot:
449+
// - Run pkexec/sudo (no privilege escalation)
450+
// - Access system package managers (apt, dnf, rpm, etc.)
451+
// - Mount AppImages (no FUSE / /dev/fuse)
452+
// - Open terminal emulators (not in the sandbox)
453+
// Instead, we open the downloaded file in the host's default file manager
454+
// via the xdg-open portal, so the user can install it natively.
455+
if (isRunningInFlatpak) {
456+
installFromFlatpak(file, ext)
457+
return@withContext
458+
}
459+
364460
when (platform) {
365461
Platform.WINDOWS -> installWindows(file, ext)
366462
Platform.MACOS -> installMacOS(file, ext)
@@ -369,6 +465,117 @@ class DesktopInstaller(
369465
}
370466
}
371467

468+
/**
469+
* Flatpak-sandboxed installation flow.
470+
*
471+
* Since we can't execute system installers, we use xdg-open (which goes through
472+
* the Flatpak portal to the host) to open the file with the host's default handler.
473+
* This lets the host's software center / file manager handle the actual installation.
474+
*/
475+
private fun installFromFlatpak(
476+
file: File,
477+
ext: String,
478+
) {
479+
Logger.i { "Running in Flatpak sandbox — delegating installation to host system" }
480+
Logger.i { "File: ${file.absolutePath} (.$ext)" }
481+
482+
when (ext) {
483+
"deb", "rpm" -> {
484+
// xdg-open goes through the Flatpak portal → opens on the host.
485+
// On most distros, .deb/.rpm files open in GNOME Software, KDE Discover,
486+
// or the default package installer.
487+
Logger.d { "Opening .$ext package via xdg-open portal for host installation" }
488+
try {
489+
val process = ProcessBuilder("xdg-open", file.absolutePath).start()
490+
val exitCode = process.waitFor()
491+
if (exitCode == 0) {
492+
Logger.i { "Package opened on host system for installation" }
493+
showFlatpakNotification(
494+
title = "Package Ready to Install",
495+
message = "The ${ext.uppercase()} package has been opened in your system's " +
496+
"software installer. Follow the prompts to complete installation.",
497+
)
498+
} else {
499+
Logger.w { "xdg-open exited with code $exitCode" }
500+
showFlatpakNotification(
501+
title = "Installation",
502+
message = "Downloaded to: ${file.absolutePath}\n" +
503+
"Please open this file with your software center to install.",
504+
)
505+
}
506+
} catch (e: Exception) {
507+
Logger.w { "Failed to open file via xdg-open: ${e.message}" }
508+
showFlatpakNotification(
509+
title = "Download Complete",
510+
message = "Downloaded to: ${file.absolutePath}\n" +
511+
"Please install manually from your file manager.",
512+
)
513+
}
514+
}
515+
516+
"appimage" -> {
517+
// AppImages can't run inside Flatpak (no FUSE), and there's no point
518+
// moving them to ~/Applications from within the sandbox.
519+
// Instead, set executable and tell the user where to find it.
520+
Logger.d { "AppImage downloaded in Flatpak — preparing for host launch" }
521+
522+
// Try to make it executable (may work if it's on a filesystem we can chmod)
523+
try {
524+
file.setExecutable(true, false)
525+
Logger.d { "Set executable permission on AppImage" }
526+
} catch (e: Exception) {
527+
Logger.w { "Could not set executable permission: ${e.message}" }
528+
}
529+
530+
showFlatpakNotification(
531+
title = "AppImage Downloaded",
532+
message = "Downloaded to: ${file.absolutePath}\n" +
533+
"Right-click → Properties → mark as executable, then double-click to run.\n" +
534+
"Or run from terminal: chmod +x '${file.name}' && ./'${file.name}'",
535+
)
536+
}
537+
538+
else -> {
539+
// Fallback: try xdg-open for any other type
540+
try {
541+
ProcessBuilder("xdg-open", file.absolutePath).start()
542+
} catch (e: Exception) {
543+
Logger.w { "Could not open file: ${e.message}" }
544+
}
545+
showFlatpakNotification(
546+
title = "Download Complete",
547+
message = "Downloaded to: ${file.absolutePath}",
548+
)
549+
}
550+
}
551+
}
552+
553+
/**
554+
* Show a notification from within the Flatpak sandbox.
555+
* Uses notify-send which goes through the desktop notifications portal.
556+
* Falls back to logging if notifications aren't available.
557+
*/
558+
private fun showFlatpakNotification(
559+
title: String,
560+
message: String,
561+
) {
562+
try {
563+
ProcessBuilder(
564+
"notify-send",
565+
"--app-name=GitHub Store",
566+
title,
567+
message,
568+
"-u",
569+
"normal",
570+
"-t",
571+
"15000",
572+
).start()
573+
} catch (e: Exception) {
574+
Logger.w { "Could not show Flatpak notification: ${e.message}" }
575+
Logger.i { "[$title] $message" }
576+
}
577+
}
578+
372579
private fun installWindows(
373580
file: File,
374581
ext: String,

0 commit comments

Comments
 (0)