Skip to content

Commit 9b66dcc

Browse files
authored
Merge pull request #251 from rainxchzed/select-version
feat(details): Implement release picker and fetch all releases
2 parents f9d50c4 + 7667aaf commit 9b66dcc

File tree

39 files changed

+940
-293
lines changed

39 files changed

+940
-293
lines changed

composeApp/build.gradle.kts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,6 @@ compose.desktop {
122122
iconFile.set(project.file("logo/app_icon.icns"))
123123
bundleID = "zed.rainxch.githubstore"
124124

125-
// Register githubstore:// URI scheme so macOS opens the app for deep links
126125
infoPlist {
127126
extraKeysRawXml = """
128127
<key>CFBundleURLTypes</key>
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,41 @@
11
package zed.rainxch.githubstore.app
22

33
import android.app.Application
4+
import android.os.Build
5+
import org.koin.android.ext.android.get
46
import org.koin.android.ext.koin.androidContext
7+
import zed.rainxch.core.data.services.PackageEventReceiver
8+
import zed.rainxch.core.domain.repository.InstalledAppsRepository
9+
import zed.rainxch.core.domain.system.PackageMonitor
510
import zed.rainxch.githubstore.app.di.initKoin
611

712
class GithubStoreApp : Application() {
813

14+
private var packageEventReceiver: PackageEventReceiver? = null
15+
916
override fun onCreate() {
1017
super.onCreate()
1118

1219
initKoin {
1320
androidContext(this@GithubStoreApp)
1421
}
22+
23+
registerPackageEventReceiver()
24+
}
25+
26+
private fun registerPackageEventReceiver() {
27+
val receiver = PackageEventReceiver(
28+
installedAppsRepository = get<InstalledAppsRepository>(),
29+
packageMonitor = get<PackageMonitor>()
30+
)
31+
val filter = PackageEventReceiver.createIntentFilter()
32+
33+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
34+
registerReceiver(receiver, filter, RECEIVER_NOT_EXPORTED)
35+
} else {
36+
registerReceiver(receiver, filter)
37+
}
38+
39+
packageEventReceiver = receiver
1540
}
1641
}

composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopDeepLink.kt

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,6 @@ import java.net.InetAddress
88
import java.net.ServerSocket
99
import java.net.Socket
1010

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

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

72-
// Already registered
7360
if (desktopFile.exists()) return
7461

7562
val exePath = resolveExePath() ?: return
@@ -88,7 +75,6 @@ object DesktopDeepLink {
8875
""".trimIndent()
8976
)
9077

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

core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,6 @@ val coreModule = module {
6363
installedAppsDao = get(),
6464
historyDao = get(),
6565
installer = get(),
66-
downloader = get(),
6766
httpClient = get()
6867
)
6968
}

core/data/src/commonMain/kotlin/zed/rainxch/core/data/mappers/ReleaseNetwork.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,5 +19,6 @@ fun ReleaseNetwork.toDomain(): GithubRelease = GithubRelease(
1919
assets = assets.map { it.toDomain() },
2020
tarballUrl = tarballUrl,
2121
zipballUrl = zipballUrl,
22-
htmlUrl = htmlUrl
22+
htmlUrl = htmlUrl,
23+
isPrerelease = prerelease == true
2324
)

core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt

Lines changed: 8 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import kotlinx.coroutines.flow.Flow
1212
import kotlinx.coroutines.flow.first
1313
import kotlinx.coroutines.flow.map
1414
import zed.rainxch.core.data.dto.ReleaseNetwork
15-
import zed.rainxch.core.data.dto.RepoByIdNetwork
15+
1616
import zed.rainxch.core.data.local.db.AppDatabase
1717
import zed.rainxch.core.data.local.db.dao.InstalledAppDao
1818
import zed.rainxch.core.data.local.db.dao.UpdateHistoryDao
@@ -24,16 +24,13 @@ import zed.rainxch.core.domain.system.Installer
2424
import zed.rainxch.core.domain.model.GithubRelease
2525
import zed.rainxch.core.domain.model.InstallSource
2626
import zed.rainxch.core.domain.model.InstalledApp
27-
import zed.rainxch.core.domain.network.Downloader
2827
import zed.rainxch.core.domain.repository.InstalledAppsRepository
29-
import java.io.File
3028

3129
class InstalledAppsRepositoryImpl(
3230
private val database: AppDatabase,
3331
private val installedAppsDao: InstalledAppDao,
3432
private val historyDao: UpdateHistoryDao,
3533
private val installer: Installer,
36-
private val downloader: Downloader,
3734
private val httpClient: HttpClient
3835
) : InstalledAppsRepository {
3936

@@ -79,21 +76,6 @@ class InstalledAppsRepositoryImpl(
7976
installedAppsDao.deleteByPackageName(packageName)
8077
}
8178

82-
private suspend fun fetchDefaultBranch(owner: String, repo: String): String? {
83-
return try {
84-
val repoInfo = httpClient.executeRequest<RepoByIdNetwork> {
85-
get("/repos/$owner/$repo") {
86-
header(HttpHeaders.Accept, "application/vnd.github+json")
87-
}
88-
}.getOrNull()
89-
90-
repoInfo?.defaultBranch
91-
} catch (e: Exception) {
92-
Logger.e { "Failed to fetch default branch for $owner/$repo: ${e.message}" }
93-
null
94-
}
95-
}
96-
9779
private suspend fun fetchLatestPublishedRelease(
9880
owner: String,
9981
repo: String
@@ -125,14 +107,6 @@ class InstalledAppsRepositoryImpl(
125107
val app = installedAppsDao.getAppByPackage(packageName) ?: return false
126108

127109
try {
128-
val branch = fetchDefaultBranch(app.repoOwner, app.repoName)
129-
130-
if (branch == null) {
131-
Logger.w { "Could not determine default branch for ${app.repoOwner}/${app.repoName}" }
132-
installedAppsDao.updateLastChecked(packageName, System.currentTimeMillis())
133-
return false
134-
}
135-
136110
val latestRelease = fetchLatestPublishedRelease(
137111
owner = app.repoOwner,
138112
repo = app.repoName
@@ -142,63 +116,16 @@ class InstalledAppsRepositoryImpl(
142116
val normalizedInstalledTag = normalizeVersion(app.installedVersion)
143117
val normalizedLatestTag = normalizeVersion(latestRelease.tagName)
144118

145-
if (normalizedInstalledTag == normalizedLatestTag) {
146-
installedAppsDao.updateVersionInfo(
147-
packageName = packageName,
148-
available = false,
149-
version = latestRelease.tagName,
150-
assetName = app.latestAssetName,
151-
assetUrl = app.latestAssetUrl,
152-
assetSize = app.latestAssetSize,
153-
releaseNotes = latestRelease.description ?: "",
154-
timestamp = System.currentTimeMillis(),
155-
latestVersionName = app.latestVersionName,
156-
latestVersionCode = app.latestVersionCode
157-
)
158-
return false
159-
}
160-
161119
val installableAssets = latestRelease.assets.filter { asset ->
162120
installer.isAssetInstallable(asset.name)
163121
}
164-
165122
val primaryAsset = installer.choosePrimaryAsset(installableAssets)
166123

167-
var isUpdateAvailable = true
168-
var latestVersionName: String? = null
169-
var latestVersionCode: Long? = null
170-
171-
if (primaryAsset != null) {
172-
val tempAssetName = primaryAsset.name + ".tmp"
173-
downloader.download(primaryAsset.downloadUrl, tempAssetName).collect { }
174-
175-
val tempPath = downloader.getDownloadedFilePath(tempAssetName)
176-
if (tempPath != null) {
177-
val latestInfo =
178-
installer.getApkInfoExtractor().extractPackageInfo(tempPath)
179-
File(tempPath).delete()
180-
181-
if (latestInfo != null) {
182-
latestVersionName = latestInfo.versionName
183-
latestVersionCode = latestInfo.versionCode
184-
isUpdateAvailable = latestVersionCode > app.installedVersionCode
185-
} else {
186-
isUpdateAvailable = false
187-
latestVersionName = latestRelease.tagName
188-
}
189-
} else {
190-
isUpdateAvailable = false
191-
latestVersionName = latestRelease.tagName
192-
}
193-
} else {
194-
isUpdateAvailable = false
195-
latestVersionName = latestRelease.tagName
196-
}
124+
val isUpdateAvailable = normalizedInstalledTag != normalizedLatestTag
197125

198126
Logger.d {
199-
"Update check for ${app.appName}: currentTag=${app.installedVersion}, latestTag=${latestRelease.tagName}, " +
200-
"currentCode=${app.installedVersionCode}, latestCode=$latestVersionCode, isUpdate=$isUpdateAvailable, " +
201-
"primaryAsset=${primaryAsset?.name}"
127+
"Update check for ${app.appName}: installedTag=${app.installedVersion}, " +
128+
"latestTag=${latestRelease.tagName}, isUpdate=$isUpdateAvailable"
202129
}
203130

204131
installedAppsDao.updateVersionInfo(
@@ -210,11 +137,13 @@ class InstalledAppsRepositoryImpl(
210137
assetSize = primaryAsset?.size,
211138
releaseNotes = latestRelease.description ?: "",
212139
timestamp = System.currentTimeMillis(),
213-
latestVersionName = latestVersionName,
214-
latestVersionCode = latestVersionCode
140+
latestVersionName = latestRelease.tagName,
141+
latestVersionCode = null
215142
)
216143

217144
return isUpdateAvailable
145+
} else {
146+
installedAppsDao.updateLastChecked(packageName, System.currentTimeMillis())
218147
}
219148
} catch (e: Exception) {
220149
Logger.e { "Failed to check updates for $packageName: ${e.message}" }

core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/GithubRelease.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,6 @@ data class GithubRelease(
1010
val assets: List<GithubAsset>,
1111
val tarballUrl: String,
1212
val zipballUrl: String,
13-
val htmlUrl: String
13+
val htmlUrl: String,
14+
val isPrerelease: Boolean = false
1415
)

core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/use_cases/SyncInstalledAppsUseCase.kt

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,13 @@ import zed.rainxch.core.domain.system.PackageMonitor
1111

1212
/**
1313
* Use case for synchronizing installed apps state with the system package manager.
14-
*
14+
*
1515
* Responsibilities:
1616
* 1. Remove apps from DB that are no longer installed on the system
1717
* 2. Migrate legacy apps missing versionName/versionCode fields
18-
*
18+
* 3. Resolve pending installs once they appear in the system package manager
19+
* 4. Clean up stale pending installs (older than 24 hours)
20+
*
1921
* This should be called before loading or refreshing app data to ensure consistency.
2022
*/
2123
class SyncInstalledAppsUseCase(
@@ -24,6 +26,9 @@ class SyncInstalledAppsUseCase(
2426
private val platform: Platform,
2527
private val logger: GitHubStoreLogger
2628
) {
29+
companion object {
30+
private const val PENDING_TIMEOUT_MS = 24 * 60 * 60 * 1000L // 24 hours
31+
}
2732
/**
2833
* Executes the sync operation.
2934
*
@@ -33,13 +38,25 @@ class SyncInstalledAppsUseCase(
3338
try {
3439
val installedPackageNames = packageMonitor.getAllInstalledPackageNames()
3540
val appsInDb = installedAppsRepository.getAllInstalledApps().first()
41+
val now = System.currentTimeMillis()
3642

3743
val toDelete = mutableListOf<String>()
3844
val toMigrate = mutableListOf<Pair<String, MigrationResult>>()
45+
val toResolvePending = mutableListOf<InstalledApp>()
46+
val toDeleteStalePending = mutableListOf<String>()
3947

4048
appsInDb.forEach { app ->
49+
val isOnSystem = installedPackageNames.contains(app.packageName)
4150
when {
42-
!installedPackageNames.contains(app.packageName) -> {
51+
app.isPendingInstall -> {
52+
if (isOnSystem) {
53+
toResolvePending.add(app)
54+
} else if (now - app.installedAt > PENDING_TIMEOUT_MS) {
55+
toDeleteStalePending.add(app.packageName)
56+
}
57+
}
58+
59+
!isOnSystem -> {
4360
toDelete.add(app.packageName)
4461
}
4562

@@ -60,6 +77,38 @@ class SyncInstalledAppsUseCase(
6077
}
6178
}
6279

80+
toDeleteStalePending.forEach { packageName ->
81+
try {
82+
installedAppsRepository.deleteInstalledApp(packageName)
83+
logger.info("Removed stale pending install (>24h): $packageName")
84+
} catch (e: Exception) {
85+
logger.error("Failed to delete stale pending $packageName: ${e.message}")
86+
}
87+
}
88+
89+
toResolvePending.forEach { app ->
90+
try {
91+
val systemInfo = packageMonitor.getInstalledPackageInfo(app.packageName)
92+
if (systemInfo != null) {
93+
val latestVersionCode = app.latestVersionCode ?: 0L
94+
installedAppsRepository.updateApp(
95+
app.copy(
96+
isPendingInstall = false,
97+
installedVersionName = systemInfo.versionName,
98+
installedVersionCode = systemInfo.versionCode,
99+
isUpdateAvailable = latestVersionCode > systemInfo.versionCode
100+
)
101+
)
102+
logger.info("Resolved pending install: ${app.packageName} (v${systemInfo.versionName}, code=${systemInfo.versionCode})")
103+
} else {
104+
installedAppsRepository.updatePendingStatus(app.packageName, false)
105+
logger.info("Resolved pending install (no system info): ${app.packageName}")
106+
}
107+
} catch (e: Exception) {
108+
logger.error("Failed to resolve pending ${app.packageName}: ${e.message}")
109+
}
110+
}
111+
63112
toMigrate.forEach { (packageName, migrationResult) ->
64113
try {
65114
val app = appsInDb.find { it.packageName == packageName } ?: return@forEach
@@ -84,7 +133,8 @@ class SyncInstalledAppsUseCase(
84133
}
85134

86135
logger.info(
87-
"Sync completed: ${toDelete.size} deleted, ${toMigrate.size} migrated"
136+
"Sync completed: ${toDelete.size} deleted, ${toDeleteStalePending.size} stale pending removed, " +
137+
"${toResolvePending.size} pending resolved, ${toMigrate.size} migrated"
88138
)
89139

90140
Result.success(Unit)

core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,4 +336,14 @@
336336
<string name="bottom_nav_apps_title">অ্যাপস</string>
337337
<string name="bottom_nav_profile_title">প্রোফাইল</string>
338338

339+
<string name="forked_repository">ফর্ক</string>
340+
341+
<string name="category_stable">স্থিতিশীল</string>
342+
<string name="category_pre_release">প্রি-রিলিজ</string>
343+
<string name="category_all">সব</string>
344+
<string name="select_version">ভার্সন নির্বাচন করুন</string>
345+
<string name="pre_release_badge">প্রি-রিলিজ</string>
346+
<string name="no_version_selected">কোনো ভার্সন নির্বাচিত নয়</string>
347+
<string name="versions_title">ভার্সনসমূহ</string>
348+
339349
</resources>

core/presentation/src/commonMain/composeResources/values-es/strings-es.xml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,4 +283,14 @@
283283
<string name="bottom_nav_apps_title">Aplicaciones</string>
284284
<string name="bottom_nav_profile_title">Perfil</string>
285285

286+
<string name="forked_repository">Bifurcar</string>
287+
288+
<string name="category_stable">Estable</string>
289+
<string name="category_pre_release">Prelanzamiento</string>
290+
<string name="category_all">Todos</string>
291+
<string name="select_version">Seleccionar versión</string>
292+
<string name="pre_release_badge">Prelanzamiento</string>
293+
<string name="no_version_selected">Ninguna versión seleccionada</string>
294+
<string name="versions_title">Versiones</string>
295+
286296
</resources>

0 commit comments

Comments
 (0)