@@ -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