@@ -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 ) {
0 commit comments