Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .claude/settings.local.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(grep -r \"\\\\.FLATPAK\\\\|Flatpak\\\\|flatpak\" D:/development/Github-Store --include=*.kt)"
]
}
}
5 changes: 5 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Flatpak packaging files must use Unix LF line endings
# (flatpak-builder on Linux can't parse JSON with CRLF)
packaging/flatpak/*.json text eol=lf
packaging/flatpak/*.yml text eol=lf
packaging/flatpak/*.sh text eol=lf
7 changes: 7 additions & 0 deletions build-logic/convention/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget

plugins {
`kotlin-dsl`
alias(libs.plugins.flatpak.gradle.generator)
}

group = "zed.rainxch.convention.buildlogic"
Expand Down Expand Up @@ -38,6 +39,12 @@ tasks {
}
}

tasks.flatpakGradleGenerator {
outputFile = file("../../packaging/flatpak/flatpak-sources-convention.json")
downloadDirectory.set("./offline-repository")
excludeConfigurations.set(listOf("testCompileClasspath", "testRuntimeClasspath"))
}

gradlePlugin {
plugins {
register("androidApplication") {
Expand Down
8 changes: 5 additions & 3 deletions build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
plugins {
id("io.github.jwharm.flatpak-gradle-generator") version "1.7.0"
alias(libs.plugins.flatpak.gradle.generator)
alias(libs.plugins.android.application) apply false
alias(libs.plugins.android.library) apply false
alias(libs.plugins.compose.hot.reload) apply false
Expand All @@ -12,8 +12,10 @@ plugins {
alias(libs.plugins.room) apply false
}

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

subprojects {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,19 @@ class DesktopInstaller(
determineSystemArchitecture()
}

/**
* Detects whether the app is running inside a Flatpak sandbox.
* Checks for the `/.flatpak-info` file which is always present inside Flatpak containers.
*/
private val isRunningInFlatpak: Boolean by lazy {
try {
File("/.flatpak-info").exists() ||
System.getenv("FLATPAK_ID") != null
} catch (_: Exception) {
false
}
}

override fun getApkInfoExtractor(): InstallerInfoExtractor = installerInfoExtractor

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

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

override fun openWithExternalInstaller(filePath: String) {
// Not applicable on desktop
}

override fun isAssetInstallable(assetName: String): Boolean {
Expand Down Expand Up @@ -108,10 +119,18 @@ class DesktopInstaller(
}

Platform.LINUX -> {
when (linuxPackageType) {
LinuxPackageType.DEB -> listOf(".appimage", ".deb", ".rpm")
LinuxPackageType.RPM -> listOf(".appimage", ".rpm", ".deb")
LinuxPackageType.UNIVERSAL -> listOf(".appimage", ".deb", ".rpm")
if (isRunningInFlatpak) {
when (linuxPackageType) {
LinuxPackageType.DEB -> listOf(".deb", ".appimage", ".rpm")
LinuxPackageType.RPM -> listOf(".rpm", ".appimage", ".deb")
LinuxPackageType.UNIVERSAL -> listOf(".appimage", ".deb", ".rpm")
}
} else {
when (linuxPackageType) {
LinuxPackageType.DEB -> listOf(".appimage", ".deb", ".rpm")
LinuxPackageType.RPM -> listOf(".appimage", ".rpm", ".deb")
LinuxPackageType.UNIVERSAL -> listOf(".appimage", ".deb", ".rpm")
}
}
}
}
Expand Down Expand Up @@ -174,6 +193,15 @@ class DesktopInstaller(
private fun determineLinuxPackageType(): LinuxPackageType {
if (platform != Platform.LINUX) return LinuxPackageType.UNIVERSAL

if (isRunningInFlatpak) {
return try {
detectHostLinuxPackageType()
} catch (e: Exception) {
Logger.w { "Failed to detect host Linux package type from Flatpak: ${e.message}" }
LinuxPackageType.UNIVERSAL
}
}

return try {
val osRelease = tryReadOsRelease()
if (osRelease != null) {
Expand Down Expand Up @@ -233,6 +261,43 @@ class DesktopInstaller(
}
}

/**
* When running inside a Flatpak sandbox, /etc/os-release belongs to the Flatpak runtime
* (e.g. org.freedesktop.Platform), not the host OS. To detect the host distro we read
* /run/host/os-release, which Flatpak bind-mounts from the host.
*/
private fun detectHostLinuxPackageType(): LinuxPackageType {
val hostOsRelease = File("/run/host/os-release")
if (!hostOsRelease.exists()) {
Logger.w { "Host os-release not available at /run/host/os-release" }
return LinuxPackageType.UNIVERSAL
}

val osRelease = parseOsRelease(hostOsRelease.readText())
val id = osRelease["ID"]?.lowercase() ?: ""
val idLike = osRelease["ID_LIKE"]?.lowercase() ?: ""

Logger.d { "Host distro detected from Flatpak: ID=$id, ID_LIKE=$idLike" }

if (id in listOf("debian", "ubuntu", "linuxmint", "pop", "elementary") ||
idLike.contains("debian") || idLike.contains("ubuntu")
) {
Logger.d { "Host is Debian-based: $id" }
return LinuxPackageType.DEB
}

if (id in listOf("fedora", "rhel", "centos", "rocky", "almalinux", "opensuse", "suse") ||
idLike.contains("fedora") || idLike.contains("rhel") ||
idLike.contains("suse") || idLike.contains("centos")
) {
Logger.d { "Host is RPM-based: $id" }
return LinuxPackageType.RPM
}

Logger.d { "Could not classify host distro, defaulting to UNIVERSAL" }
return LinuxPackageType.UNIVERSAL
}

private fun tryReadOsRelease(): Map<String, String>? {
val osReleaseFiles =
listOf(
Expand Down Expand Up @@ -318,6 +383,11 @@ class DesktopInstaller(
withContext(Dispatchers.IO) {
val ext = extOrMime.lowercase().removePrefix(".")

if (isRunningInFlatpak) {
Logger.d { "Running in Flatpak — skipping permission checks for .$ext" }
return@withContext
}

if (platform == Platform.LINUX && ext == "appimage") {
try {
val tempFile = File.createTempFile("appimage_perm_test", ".tmp")
Expand All @@ -326,7 +396,7 @@ class DesktopInstaller(
if (!canSetExecutable) {
throw IllegalStateException(
"Unable to set executable permissions. AppImage installation requires " +
"the ability to make files executable.",
"the ability to make files executable.",
)
}
} finally {
Expand All @@ -347,7 +417,6 @@ class DesktopInstaller(
}

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

Expand All @@ -363,6 +432,11 @@ class DesktopInstaller(

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

if (isRunningInFlatpak) {
installFromFlatpak(file, ext)
return@withContext InstallOutcome.DELEGATED_TO_SYSTEM
}

when (platform) {
Platform.WINDOWS -> installWindows(file, ext)
Platform.MACOS -> installMacOS(file, ext)
Expand All @@ -371,7 +445,145 @@ class DesktopInstaller(
}

InstallOutcome.DELEGATED_TO_SYSTEM

}

/**
* Flatpak-sandboxed installation flow.
*
* Since we can't execute system installers, we use xdg-open (which goes through
* the Flatpak portal to the host) to open the file with the host's default handler.
* This lets the host's software center / file manager handle the actual installation.
*/
private fun installFromFlatpak(
file: File,
ext: String,
) {
Logger.i { "Running in Flatpak sandbox — delegating installation to host system" }
Logger.i { "File: ${file.absolutePath} (.$ext)" }

when (ext) {
"deb", "rpm" -> {
Logger.d { "Opening .$ext package via xdg-open portal for host installation" }
try {
val process = ProcessBuilder("xdg-open", file.absolutePath).start()
val exitCode = process.waitFor()
if (exitCode == 0) {
Logger.i { "Package opened on host system for installation" }
showFlatpakNotification(
title = "Package Ready to Install",
message = "The ${ext.uppercase()} package has been opened in your system's " +
"software installer. Follow the prompts to complete installation.",
)
} else {
Logger.w { "xdg-open exited with code $exitCode" }
showFlatpakNotification(
title = "Installation",
message = "Please open this file with your software center to install.",
)
openInFileManager(file)
}
} catch (e: Exception) {
Logger.w { "Failed to open file via xdg-open: ${e.message}" }
showFlatpakNotification(
title = "Download Complete",
message = "Please install manually from your file manager.",
)
openInFileManager(file)
}
}

"appimage" -> {
Logger.d { "AppImage downloaded in Flatpak — preparing for host launch" }

try {
file.setExecutable(true, false)
Logger.d { "Set executable permission on AppImage" }
} catch (e: Exception) {
Logger.w { "Could not set executable permission: ${e.message}" }
}

showFlatpakNotification(
title = "AppImage Downloaded",
message = "Right-click → Properties → mark as executable, then double-click to run.",
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

openInFileManager(file)
}

else -> {
showFlatpakNotification(
title = "Download Complete",
message = "File saved to your Downloads folder.",
)
openInFileManager(file)
}
}
}

/**
* Show a notification from within the Flatpak sandbox.
* Uses notify-send which goes through the desktop notifications portal.
* Falls back to logging if notifications aren't available.
*/
private fun showFlatpakNotification(
title: String,
message: String,
) {
try {
ProcessBuilder(
"notify-send",
"--app-name=GitHub Store",
title,
message,
"-u",
"normal",
"-t",
"15000",
).start()
} catch (e: Exception) {
Logger.w { "Could not show Flatpak notification: ${e.message}" }
Logger.i { "[$title] $message" }
}
}

/**
* Opens the system file manager with the given file highlighted/selected.
*
* Tries D-Bus FileManager1.ShowItems first (works on GNOME, KDE, etc. and
* goes through the Flatpak portal), then falls back to xdg-open on the
* parent directory.
*/
private fun openInFileManager(file: File) {
try {
val fileUri = "file://${file.absolutePath}"
val process = ProcessBuilder(
"gdbus", "call",
"--session",
"--dest", "org.freedesktop.FileManager1",
"--object-path", "/org/freedesktop/FileManager1",
"--method", "org.freedesktop.FileManager1.ShowItems",
"['$fileUri']", "",
).start()
val exitCode = process.waitFor()

if (exitCode == 0) {
Logger.d { "Opened file manager via D-Bus ShowItems: ${file.absolutePath}" }
return
}
Logger.w { "D-Bus ShowItems failed with exit code $exitCode" }
} catch (e: Exception) {
Logger.w { "D-Bus ShowItems not available: ${e.message}" }
}
Comment on lines +557 to +577
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if FileManager1 D-Bus permission is declared
grep -r "FileManager1" packaging/flatpak/ 2>/dev/null || echo "No FileManager1 permission found"
echo "---"
# Check finish-args for talk-name permissions
grep -A 30 "finish-args" packaging/flatpak/zed.rainxch.githubstore.yml | grep "talk-name" || echo "No talk-name permissions declared"

Repository: OpenHub-Store/GitHub-Store

Length of output: 140


🏁 Script executed:

#!/bin/bash
# Check if file exists and get line count
if [ -f "core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopInstaller.kt" ]; then
    wc -l "core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopInstaller.kt"
else
    echo "File not found at expected path"
    # Try to find it
    find . -name "DesktopInstaller.kt" 2>/dev/null
fi

Repository: OpenHub-Store/GitHub-Store

Length of output: 154


🏁 Script executed:

#!/bin/bash
# Read the openInFileManager method context (lines 575-614)
if [ -f "core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopInstaller.kt" ]; then
    echo "=== Lines 575-614 ==="
    sed -n '575,614p' "core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopInstaller.kt"
else
    echo "File not found, searching..."
    find . -name "DesktopInstaller.kt" -exec echo "Found: {}" \; -exec head -650 {} \; 2>/dev/null | tail -100
fi

Repository: OpenHub-Store/GitHub-Store

Length of output: 1794


🏁 Script executed:

#!/bin/bash
# Verify package naming compliance with coding guideline: zed.rainxch.{module}.{layer}
grep -n "^package " "core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopInstaller.kt" 2>/dev/null || echo "Package declaration not found"

Repository: OpenHub-Store/GitHub-Store

Length of output: 110


D-Bus FileManager1 call will fail in Flatpak; GVariant argument is injection-vulnerable.

Two issues:

  1. Missing D-Bus permission: The code comment claims the D-Bus call "works via Flatpak portal," but this is misleading. The org.freedesktop.FileManager1 service is not a Flatpak portal — it requires explicit --talk-name=org.freedesktop.FileManager1 in the manifest's finish-args. This permission is not currently declared, so the gdbus call will fail with a permission error and always fall back to xdg-open. Either add the permission to the manifest, or remove this dead code path entirely and rely solely on xdg-open.

  2. GVariant format injection: The file URI is interpolated directly into ['$fileUri']. Paths containing ' or ] will break GVariant parsing. For example, a file named app'name.deb produces ['file:///path/app'name.deb'] with unbalanced quotes. Escape single quotes in the path: val escapedPath = file.absolutePath.replace("'", "\\'") and use it in the GVariant argument.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopInstaller.kt`
around lines 582 - 604, The openInFileManager method currently attempts a gdbus
ProcessBuilder call to org.freedesktop.FileManager1 which will fail inside
Flatpak unless the manifest includes --talk-name=org.freedesktop.FileManager1,
and it constructs a GVariant string by interpolating fileUri unsafely
(vulnerable to single-quote/] injection). Fix by either removing this D-Bus
branch and relying on xdg-open, or ensure the Flatpak manifest grants
talk-name=org.freedesktop.FileManager1; if keeping the gdbus call, sanitize the
path before building the GVariant: compute an escapedPath from file.absolutePath
that replaces single quotes and other problematic chars (e.g., replace "'" with
"\\'") and use that escaped value when constructing the GVariant argument passed
to ProcessBuilder (identify openInFileManager, fileUri/escapedPath, and the
ProcessBuilder call to locate the code).


try {
val parentDir = file.parentFile ?: return
ProcessBuilder("xdg-open", parentDir.absolutePath).start()
Logger.d { "Opened parent directory: ${parentDir.absolutePath}" }
} catch (e: Exception) {
Logger.w { "Could not open file manager: ${e.message}" }
}
}

private fun installWindows(
file: File,
Expand Down Expand Up @@ -507,7 +719,12 @@ class DesktopInstaller(
val installMethods =
listOf(
listOf("pkexec", "apt", "install", "-y", file.absolutePath),
listOf("pkexec", "sh", "-c", "dpkg -i '${file.absolutePath}' || apt-get install -f -y"),
listOf(
"pkexec",
"sh",
"-c",
"dpkg -i '${file.absolutePath}' || apt-get install -f -y"
),
listOf("gdebi-gtk", file.absolutePath),
null,
)
Expand Down Expand Up @@ -899,7 +1116,7 @@ class DesktopInstaller(
e.printStackTrace()
throw IllegalStateException(
"Failed to install AppImage: ${e.message}. " +
"Please ensure you have write permissions to ~/Applications folder.",
"Please ensure you have write permissions to ~/Applications folder.",
e,
)
} catch (e: SecurityException) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1073,6 +1073,7 @@ class DetailsViewModel(
installOutcome = installOutcome,
)
} else if (platform != Platform.ANDROID) {
cachedDownloadAssetName = null
viewModelScope.launch {
_events.send(DetailsEvent.OnMessage(getString(Res.string.installer_saved_downloads)))
}
Expand Down
Loading