Skip to content
1 change: 0 additions & 1 deletion composeApp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,6 @@ compose.desktop {
iconFile.set(project.file("logo/app_icon.icns"))
bundleID = "zed.rainxch.githubstore"

// Register githubstore:// URI scheme so macOS opens the app for deep links
infoPlist {
extraKeysRawXml = """
<key>CFBundleURLTypes</key>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,41 @@
package zed.rainxch.githubstore.app

import android.app.Application
import android.os.Build
import org.koin.android.ext.android.get
import org.koin.android.ext.koin.androidContext
import zed.rainxch.core.data.services.PackageEventReceiver
import zed.rainxch.core.domain.repository.InstalledAppsRepository
import zed.rainxch.core.domain.system.PackageMonitor
import zed.rainxch.githubstore.app.di.initKoin

class GithubStoreApp : Application() {

private var packageEventReceiver: PackageEventReceiver? = null

override fun onCreate() {
super.onCreate()

initKoin {
androidContext(this@GithubStoreApp)
}

registerPackageEventReceiver()
}

private fun registerPackageEventReceiver() {
val receiver = PackageEventReceiver(
installedAppsRepository = get<InstalledAppsRepository>(),
packageMonitor = get<PackageMonitor>()
)
val filter = PackageEventReceiver.createIntentFilter()

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(receiver, filter, RECEIVER_NOT_EXPORTED)
} else {
registerReceiver(receiver, filter)
}

packageEventReceiver = receiver
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,6 @@ import java.net.InetAddress
import java.net.ServerSocket
import java.net.Socket

/**
* Handles desktop deep link registration and single-instance forwarding.
*
* - **Windows**: Registers `githubstore://` in HKCU registry on first launch.
* URI is received as a CLI argument (`args[0]`).
* - **macOS**: URI scheme is registered via Info.plist in the packaged .app.
* URI is received via `Desktop.setOpenURIHandler`.
* - **Linux**: Registers `githubstore://` via a `.desktop` file + `xdg-mime` on first launch.
* URI is received as a CLI argument (`args[0]`).
* - **Single-instance**: Uses a local TCP socket to forward URIs from
* a second instance to the already-running primary instance.
*/
object DesktopDeepLink {

private const val SINGLE_INSTANCE_PORT = 47632
Expand Down Expand Up @@ -69,7 +57,6 @@ object DesktopDeepLink {
val appsDir = File(System.getProperty("user.home"), ".local/share/applications")
val desktopFile = File(appsDir, "$DESKTOP_FILE_NAME.desktop")

// Already registered
if (desktopFile.exists()) return

val exePath = resolveExePath() ?: return
Expand All @@ -88,7 +75,6 @@ object DesktopDeepLink {
""".trimIndent()
)

// Register as the default handler for githubstore:// URIs
runCommand("xdg-mime", "default", "$DESKTOP_FILE_NAME.desktop", "x-scheme-handler/$SCHEME")
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,6 @@ val coreModule = module {
installedAppsDao = get(),
historyDao = get(),
installer = get(),
downloader = get(),
httpClient = get()
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,6 @@ fun ReleaseNetwork.toDomain(): GithubRelease = GithubRelease(
assets = assets.map { it.toDomain() },
tarballUrl = tarballUrl,
zipballUrl = zipballUrl,
htmlUrl = htmlUrl
htmlUrl = htmlUrl,
isPrerelease = prerelease == true
)
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import zed.rainxch.core.data.dto.ReleaseNetwork
import zed.rainxch.core.data.dto.RepoByIdNetwork

import zed.rainxch.core.data.local.db.AppDatabase
import zed.rainxch.core.data.local.db.dao.InstalledAppDao
import zed.rainxch.core.data.local.db.dao.UpdateHistoryDao
Expand All @@ -24,16 +24,13 @@ import zed.rainxch.core.domain.system.Installer
import zed.rainxch.core.domain.model.GithubRelease
import zed.rainxch.core.domain.model.InstallSource
import zed.rainxch.core.domain.model.InstalledApp
import zed.rainxch.core.domain.network.Downloader
import zed.rainxch.core.domain.repository.InstalledAppsRepository
import java.io.File

class InstalledAppsRepositoryImpl(
private val database: AppDatabase,
private val installedAppsDao: InstalledAppDao,
private val historyDao: UpdateHistoryDao,
private val installer: Installer,
private val downloader: Downloader,
private val httpClient: HttpClient
) : InstalledAppsRepository {

Expand Down Expand Up @@ -79,21 +76,6 @@ class InstalledAppsRepositoryImpl(
installedAppsDao.deleteByPackageName(packageName)
}

private suspend fun fetchDefaultBranch(owner: String, repo: String): String? {
return try {
val repoInfo = httpClient.executeRequest<RepoByIdNetwork> {
get("/repos/$owner/$repo") {
header(HttpHeaders.Accept, "application/vnd.github+json")
}
}.getOrNull()

repoInfo?.defaultBranch
} catch (e: Exception) {
Logger.e { "Failed to fetch default branch for $owner/$repo: ${e.message}" }
null
}
}

private suspend fun fetchLatestPublishedRelease(
owner: String,
repo: String
Expand Down Expand Up @@ -125,14 +107,6 @@ class InstalledAppsRepositoryImpl(
val app = installedAppsDao.getAppByPackage(packageName) ?: return false

try {
val branch = fetchDefaultBranch(app.repoOwner, app.repoName)

if (branch == null) {
Logger.w { "Could not determine default branch for ${app.repoOwner}/${app.repoName}" }
installedAppsDao.updateLastChecked(packageName, System.currentTimeMillis())
return false
}

val latestRelease = fetchLatestPublishedRelease(
owner = app.repoOwner,
repo = app.repoName
Expand All @@ -142,63 +116,16 @@ class InstalledAppsRepositoryImpl(
val normalizedInstalledTag = normalizeVersion(app.installedVersion)
val normalizedLatestTag = normalizeVersion(latestRelease.tagName)

if (normalizedInstalledTag == normalizedLatestTag) {
installedAppsDao.updateVersionInfo(
packageName = packageName,
available = false,
version = latestRelease.tagName,
assetName = app.latestAssetName,
assetUrl = app.latestAssetUrl,
assetSize = app.latestAssetSize,
releaseNotes = latestRelease.description ?: "",
timestamp = System.currentTimeMillis(),
latestVersionName = app.latestVersionName,
latestVersionCode = app.latestVersionCode
)
return false
}

val installableAssets = latestRelease.assets.filter { asset ->
installer.isAssetInstallable(asset.name)
}

val primaryAsset = installer.choosePrimaryAsset(installableAssets)

var isUpdateAvailable = true
var latestVersionName: String? = null
var latestVersionCode: Long? = null

if (primaryAsset != null) {
val tempAssetName = primaryAsset.name + ".tmp"
downloader.download(primaryAsset.downloadUrl, tempAssetName).collect { }

val tempPath = downloader.getDownloadedFilePath(tempAssetName)
if (tempPath != null) {
val latestInfo =
installer.getApkInfoExtractor().extractPackageInfo(tempPath)
File(tempPath).delete()

if (latestInfo != null) {
latestVersionName = latestInfo.versionName
latestVersionCode = latestInfo.versionCode
isUpdateAvailable = latestVersionCode > app.installedVersionCode
} else {
isUpdateAvailable = false
latestVersionName = latestRelease.tagName
}
} else {
isUpdateAvailable = false
latestVersionName = latestRelease.tagName
}
} else {
isUpdateAvailable = false
latestVersionName = latestRelease.tagName
}
val isUpdateAvailable = normalizedInstalledTag != normalizedLatestTag

Logger.d {
"Update check for ${app.appName}: currentTag=${app.installedVersion}, latestTag=${latestRelease.tagName}, " +
"currentCode=${app.installedVersionCode}, latestCode=$latestVersionCode, isUpdate=$isUpdateAvailable, " +
"primaryAsset=${primaryAsset?.name}"
"Update check for ${app.appName}: installedTag=${app.installedVersion}, " +
"latestTag=${latestRelease.tagName}, isUpdate=$isUpdateAvailable"
}

installedAppsDao.updateVersionInfo(
Expand All @@ -210,11 +137,13 @@ class InstalledAppsRepositoryImpl(
assetSize = primaryAsset?.size,
releaseNotes = latestRelease.description ?: "",
timestamp = System.currentTimeMillis(),
latestVersionName = latestVersionName,
latestVersionCode = latestVersionCode
latestVersionName = latestRelease.tagName,
latestVersionCode = null
)

return isUpdateAvailable
} else {
installedAppsDao.updateLastChecked(packageName, System.currentTimeMillis())
}
} catch (e: Exception) {
Logger.e { "Failed to check updates for $packageName: ${e.message}" }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@ data class GithubRelease(
val assets: List<GithubAsset>,
val tarballUrl: String,
val zipballUrl: String,
val htmlUrl: String
val htmlUrl: String,
val isPrerelease: Boolean = false
)
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@ import zed.rainxch.core.domain.system.PackageMonitor

/**
* Use case for synchronizing installed apps state with the system package manager.
*
*
* Responsibilities:
* 1. Remove apps from DB that are no longer installed on the system
* 2. Migrate legacy apps missing versionName/versionCode fields
*
* 3. Resolve pending installs once they appear in the system package manager
* 4. Clean up stale pending installs (older than 24 hours)
*
* This should be called before loading or refreshing app data to ensure consistency.
*/
class SyncInstalledAppsUseCase(
Expand All @@ -24,6 +26,9 @@ class SyncInstalledAppsUseCase(
private val platform: Platform,
private val logger: GitHubStoreLogger
) {
companion object {
private const val PENDING_TIMEOUT_MS = 24 * 60 * 60 * 1000L // 24 hours
}
/**
* Executes the sync operation.
*
Expand All @@ -33,13 +38,25 @@ class SyncInstalledAppsUseCase(
try {
val installedPackageNames = packageMonitor.getAllInstalledPackageNames()
val appsInDb = installedAppsRepository.getAllInstalledApps().first()
val now = System.currentTimeMillis()

val toDelete = mutableListOf<String>()
val toMigrate = mutableListOf<Pair<String, MigrationResult>>()
val toResolvePending = mutableListOf<InstalledApp>()
val toDeleteStalePending = mutableListOf<String>()

appsInDb.forEach { app ->
val isOnSystem = installedPackageNames.contains(app.packageName)
when {
!installedPackageNames.contains(app.packageName) -> {
app.isPendingInstall -> {
if (isOnSystem) {
toResolvePending.add(app)
} else if (now - app.installedAt > PENDING_TIMEOUT_MS) {
toDeleteStalePending.add(app.packageName)
}
}

!isOnSystem -> {
toDelete.add(app.packageName)
}

Expand All @@ -60,6 +77,38 @@ class SyncInstalledAppsUseCase(
}
}

toDeleteStalePending.forEach { packageName ->
try {
installedAppsRepository.deleteInstalledApp(packageName)
logger.info("Removed stale pending install (>24h): $packageName")
} catch (e: Exception) {
logger.error("Failed to delete stale pending $packageName: ${e.message}")
}
}

toResolvePending.forEach { app ->
try {
val systemInfo = packageMonitor.getInstalledPackageInfo(app.packageName)
if (systemInfo != null) {
val latestVersionCode = app.latestVersionCode ?: 0L
installedAppsRepository.updateApp(
app.copy(
isPendingInstall = false,
installedVersionName = systemInfo.versionName,
installedVersionCode = systemInfo.versionCode,
isUpdateAvailable = latestVersionCode > systemInfo.versionCode
)
)
logger.info("Resolved pending install: ${app.packageName} (v${systemInfo.versionName}, code=${systemInfo.versionCode})")
} else {
installedAppsRepository.updatePendingStatus(app.packageName, false)
logger.info("Resolved pending install (no system info): ${app.packageName}")
}
} catch (e: Exception) {
logger.error("Failed to resolve pending ${app.packageName}: ${e.message}")
}
}

toMigrate.forEach { (packageName, migrationResult) ->
try {
val app = appsInDb.find { it.packageName == packageName } ?: return@forEach
Expand All @@ -84,7 +133,8 @@ class SyncInstalledAppsUseCase(
}

logger.info(
"Sync completed: ${toDelete.size} deleted, ${toMigrate.size} migrated"
"Sync completed: ${toDelete.size} deleted, ${toDeleteStalePending.size} stale pending removed, " +
"${toResolvePending.size} pending resolved, ${toMigrate.size} migrated"
)

Result.success(Unit)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -336,4 +336,14 @@
<string name="bottom_nav_apps_title">অ্যাপস</string>
<string name="bottom_nav_profile_title">প্রোফাইল</string>

<string name="forked_repository">ফর্ক</string>

<string name="category_stable">স্থিতিশীল</string>
<string name="category_pre_release">প্রি-রিলিজ</string>
<string name="category_all">সব</string>
<string name="select_version">ভার্সন নির্বাচন করুন</string>
<string name="pre_release_badge">প্রি-রিলিজ</string>
<string name="no_version_selected">কোনো ভার্সন নির্বাচিত নয়</string>
<string name="versions_title">ভার্সনসমূহ</string>

</resources>
Original file line number Diff line number Diff line change
Expand Up @@ -283,4 +283,14 @@
<string name="bottom_nav_apps_title">Aplicaciones</string>
<string name="bottom_nav_profile_title">Perfil</string>

<string name="forked_repository">Bifurcar</string>
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Minor translation concern: forked_repository uses verb infinitive.

"Bifurcar" is the infinitive ("To fork"), while this key appears to be used as a label/badge for forked repositories. Consider "Bifurcación" (noun) or "Bifurcado" (adjective) to match the label semantics used in other locales.

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

In `@core/presentation/src/commonMain/composeResources/values-es/strings-es.xml`
at line 286, The string resource forked_repository currently uses the infinitive
"Bifurcar"; change its value to a noun or adjective to match label semantics
(e.g., "Bifurcación" or "Bifurcado") in the file where string
name="forked_repository" is defined so the translation aligns with other locales
and serves as a repository badge/label rather than an action.


<string name="category_stable">Estable</string>
<string name="category_pre_release">Prelanzamiento</string>
<string name="category_all">Todos</string>
<string name="select_version">Seleccionar versión</string>
<string name="pre_release_badge">Prelanzamiento</string>
<string name="no_version_selected">Ninguna versión seleccionada</string>
<string name="versions_title">Versiones</string>

</resources>
Loading