diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts
index 9117f5e9..94442133 100644
--- a/composeApp/build.gradle.kts
+++ b/composeApp/build.gradle.kts
@@ -121,6 +121,23 @@ compose.desktop {
macOS {
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 = """
+ CFBundleURLTypes
+
+
+ CFBundleURLName
+ GitHub Store Deep Link
+ CFBundleURLSchemes
+
+ githubstore
+
+
+
+ """.trimIndent()
+ }
}
linux {
iconFile.set(project.file("logo/app_icon.png"))
diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml
index cc77a1e9..2074744d 100644
--- a/composeApp/src/androidMain/AndroidManifest.xml
+++ b/composeApp/src/androidMain/AndroidManifest.xml
@@ -27,13 +27,15 @@
+ android:exported="true"
+ android:launchMode="singleTask">
+
@@ -44,6 +46,44 @@
android:host="callback"
android:scheme="githubstore" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
(null)
+
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
@@ -16,8 +25,22 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState)
+ deepLinkUri = intent?.data?.toString()
+
setContent {
- App()
+ DisposableEffect(Unit) {
+ val listener = Consumer { newIntent ->
+ newIntent.data?.toString()?.let {
+ deepLinkUri = it
+ }
+ }
+ addOnNewIntentListener(listener)
+ onDispose {
+ removeOnNewIntentListener(listener)
+ }
+ }
+
+ App(deepLinkUri = deepLinkUri)
}
}
}
diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/Main.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/Main.kt
index 31a4057e..e114987c 100644
--- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/Main.kt
+++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/Main.kt
@@ -3,6 +3,7 @@ package zed.rainxch.githubstore
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.compose.rememberNavController
@@ -10,6 +11,8 @@ import org.jetbrains.compose.ui.tooling.preview.Preview
import org.koin.compose.viewmodel.koinViewModel
import zed.rainxch.core.presentation.theme.GithubStoreTheme
import zed.rainxch.core.presentation.utils.ApplyAndroidSystemBars
+import zed.rainxch.githubstore.app.deeplink.DeepLinkDestination
+import zed.rainxch.githubstore.app.deeplink.DeepLinkParser
import zed.rainxch.githubstore.app.navigation.AppNavigation
import zed.rainxch.githubstore.app.navigation.GithubStoreGraph
import zed.rainxch.githubstore.app.components.RateLimitDialog
@@ -17,12 +20,30 @@ import zed.rainxch.githubstore.app.components.RateLimitDialog
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
@Preview
-fun App() {
+fun App(deepLinkUri: String? = null) {
val viewModel: MainViewModel = koinViewModel()
val state by viewModel.state.collectAsStateWithLifecycle()
val navBackStack = rememberNavController()
+ LaunchedEffect(deepLinkUri) {
+ deepLinkUri?.let { uri ->
+ when (val destination = DeepLinkParser.parse(uri)) {
+ is DeepLinkDestination.Repository -> {
+ navBackStack.navigate(
+ GithubStoreGraph.DetailsScreen(
+ owner = destination.owner,
+ repo = destination.repo
+ )
+ )
+ }
+
+ DeepLinkDestination.None -> { /* ignore unrecognized deep links */
+ }
+ }
+ }
+ }
+
GithubStoreTheme(
fontTheme = state.currentFontTheme,
appTheme = state.currentColorTheme,
@@ -31,19 +52,21 @@ fun App() {
) {
ApplyAndroidSystemBars(state.isDarkTheme)
- if (state.showRateLimitDialog && state.rateLimitInfo != null) {
- RateLimitDialog(
- rateLimitInfo = state.rateLimitInfo,
- isAuthenticated = state.isLoggedIn,
- onDismiss = {
- viewModel.onAction(MainAction.DismissRateLimitDialog)
- },
- onSignIn = {
- viewModel.onAction(MainAction.DismissRateLimitDialog)
-
- navBackStack.navigate(GithubStoreGraph.AuthenticationScreen)
- }
- )
+ if (state.showRateLimitDialog) {
+ state.rateLimitInfo?.let {
+ RateLimitDialog(
+ rateLimitInfo = it,
+ isAuthenticated = state.isLoggedIn,
+ onDismiss = {
+ viewModel.onAction(MainAction.DismissRateLimitDialog)
+ },
+ onSignIn = {
+ viewModel.onAction(MainAction.DismissRateLimitDialog)
+
+ navBackStack.navigate(GithubStoreGraph.AuthenticationScreen)
+ }
+ )
+ }
}
AppNavigation(
diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/components/RateLimitDialog.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/components/RateLimitDialog.kt
index b1acae72..5d0b5f38 100644
--- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/components/RateLimitDialog.kt
+++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/components/RateLimitDialog.kt
@@ -25,13 +25,13 @@ import zed.rainxch.githubstore.core.presentation.res.*
@Composable
fun RateLimitDialog(
- rateLimitInfo: RateLimitInfo?,
+ rateLimitInfo: RateLimitInfo,
isAuthenticated: Boolean,
onDismiss: () -> Unit,
onSignIn: () -> Unit
) {
val timeUntilReset = remember(rateLimitInfo) {
- rateLimitInfo?.timeUntilReset()?.inWholeMinutes?.toInt()
+ rateLimitInfo.timeUntilReset().inWholeMinutes.toInt()
}
AlertDialog(
@@ -59,12 +59,12 @@ fun RateLimitDialog(
text = if (isAuthenticated) {
stringResource(
Res.string.rate_limit_used_all,
- rateLimitInfo?.limit ?: 0
+ rateLimitInfo.limit
)
} else {
stringResource(
Res.string.rate_limit_used_all_free,
- rateLimitInfo?.limit ?: 0
+ 60
)
},
style = MaterialTheme.typography.bodyMedium,
@@ -74,7 +74,7 @@ fun RateLimitDialog(
Text(
text = stringResource(
Res.string.rate_limit_resets_in_minutes,
- timeUntilReset ?: 0
+ timeUntilReset
),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
@@ -83,6 +83,7 @@ fun RateLimitDialog(
if (!isAuthenticated) {
Spacer(modifier = Modifier.height(8.dp))
+
Text(
text = stringResource(Res.string.rate_limit_tip_sign_in),
style = MaterialTheme.typography.bodySmall,
@@ -127,7 +128,11 @@ fun RateLimitDialog(
fun RateLimitDialogPreview() {
GithubStoreTheme {
RateLimitDialog(
- rateLimitInfo = null,
+ rateLimitInfo = RateLimitInfo(
+ limit = 1000,
+ remaining = 2000,
+ resetTimestamp = 0L,
+ ),
isAuthenticated = false,
onDismiss = {
diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/deeplink/DeepLinkParser.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/deeplink/DeepLinkParser.kt
new file mode 100644
index 00000000..0a10a064
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/deeplink/DeepLinkParser.kt
@@ -0,0 +1,177 @@
+package zed.rainxch.githubstore.app.deeplink
+
+sealed interface DeepLinkDestination {
+ data class Repository(val owner: String, val repo: String) : DeepLinkDestination
+ data object None : DeepLinkDestination
+}
+
+object DeepLinkParser {
+ private val INVALID_CHARS = setOf('/', '\\', '?', '#', '@', ':', '*', '"', '<', '>', '|', '%', '&', '=')
+
+ private val FORBIDDEN_PATTERNS = listOf("..", "~", "\u0000")
+
+ private val EXCLUDED_PATHS = setOf(
+ "about", "account", "admin", "api", "apps", "articles",
+ "blog", "business", "collections", "contact", "dashboard",
+ "enterprises", "events", "explore", "features", "home",
+ "issues", "marketplace", "new", "notifications", "orgs",
+ "pricing", "pulls", "search", "security", "settings",
+ "showcases", "site", "sponsors", "topics", "trending", "team"
+ )
+
+ fun parse(uri: String): DeepLinkDestination {
+ return when {
+ uri.startsWith("githubstore://repo/") -> {
+ val path = uri.removePrefix("githubstore://repo/")
+ val decoded = urlDecode(path)
+ parseOwnerRepo(decoded)
+ }
+
+ uri.startsWith("https://github.com/") -> {
+ val path = uri.removePrefix("https://github.com/")
+ .substringBefore('?')
+ .substringBefore('#')
+ val decoded = urlDecode(path)
+
+ val parts = decoded.split("/").filter { it.isNotEmpty() }
+ if (parts.size >= 2) {
+ val owner = parts[0]
+ val repo = parts[1]
+ if (isStrictlyValidOwnerRepo(owner, repo)) {
+ return DeepLinkDestination.Repository(owner, repo)
+ }
+ }
+ DeepLinkDestination.None
+ }
+
+ uri.startsWith("https://github-store.org/app/") -> {
+ extractQueryParam(uri, "repo")?.let { encodedRepoParam ->
+ val decoded = urlDecode(encodedRepoParam)
+ parseOwnerRepo(decoded)
+ } ?: DeepLinkDestination.None
+ }
+
+ else -> DeepLinkDestination.None
+ }
+ }
+
+ /**
+ * URL-decode a string, handling percent-encoded characters.
+ * Returns the original string if decoding fails.
+ */
+ private fun urlDecode(value: String): String {
+ return try {
+ val result = StringBuilder()
+ var i = 0
+ while (i < value.length) {
+ when (val c = value[i]) {
+ '%' -> {
+ if (i + 2 < value.length) {
+ val hex = value.substring(i + 1, i + 3)
+ val code = hex.toIntOrNull(16)
+ if (code != null) {
+ result.append(code.toChar())
+ i += 3
+ continue
+ }
+ }
+ result.append(c)
+ i++
+ }
+ '+' -> {
+ result.append(' ')
+ i++
+ }
+ else -> {
+ result.append(c)
+ i++
+ }
+ }
+ }
+ result.toString()
+ } catch (e: Exception) {
+ value
+ }
+ }
+
+ private fun parseOwnerRepo(path: String): DeepLinkDestination {
+ val parts = path.split("/").filter { it.isNotEmpty() }
+ return if (parts.size >= 2) {
+ val owner = parts[0]
+ val repo = parts[1]
+ if (isStrictlyValidOwnerRepo(owner, repo)) {
+ DeepLinkDestination.Repository(owner, repo)
+ } else {
+ DeepLinkDestination.None
+ }
+ } else {
+ DeepLinkDestination.None
+ }
+ }
+
+ /**
+ * Strictly validate owner and repo names to prevent injection attacks.
+ * Rejects:
+ * - Empty strings
+ * - Special characters that could be used for injection
+ * - Path traversal patterns
+ * - Control characters and whitespace
+ * - Excluded GitHub paths (like 'about', 'settings', etc.)
+ * - Names that exceed GitHub's length limits
+ * - Names that don't start with alphanumeric characters
+ */
+ private fun isStrictlyValidOwnerRepo(owner: String, repo: String): Boolean {
+ if (owner.isEmpty() || repo.isEmpty()) {
+ return false
+ }
+
+ if (owner.any { it in INVALID_CHARS } || repo.any { it in INVALID_CHARS }) {
+ return false
+ }
+
+ if (FORBIDDEN_PATTERNS.any { pattern ->
+ owner.contains(pattern, ignoreCase = true) ||
+ repo.contains(pattern, ignoreCase = true)
+ }) {
+ return false
+ }
+
+ if (owner.any { it.isISOControl() } || repo.any { it.isISOControl() }) {
+ return false
+ }
+
+ if (owner.contains(' ') || repo.contains(' ')) {
+ return false
+ }
+
+ if (EXCLUDED_PATHS.contains(owner.lowercase())) {
+ return false
+ }
+
+ if (owner.length > 39 || repo.length > 100) {
+ return false
+ }
+
+ if (!owner.first().isLetterOrDigit() || !repo.first().isLetterOrDigit()) {
+ return false
+ }
+
+ return true
+ }
+
+ private fun extractQueryParam(uri: String, key: String): String? {
+ val queryStart = uri.indexOf('?')
+ if (queryStart == -1) return null
+
+ val queryString = uri.substring(queryStart + 1)
+ val params = queryString.split('&')
+
+ for (param in params) {
+ val keyValue = param.split('=', limit = 2)
+ if (keyValue.size == 2 && keyValue[0] == key) {
+ return keyValue[1]
+ }
+ }
+ return null
+ }
+}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt
index 96a34dfc..07874eea 100644
--- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt
+++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt
@@ -118,7 +118,7 @@ fun AppNavigation(
)
},
viewModel = koinViewModel {
- parametersOf(args.repositoryId)
+ parametersOf(args.repositoryId, args.owner, args.repo)
}
)
}
diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/GithubStoreGraph.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/GithubStoreGraph.kt
index aa4142e9..6ec8640f 100644
--- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/GithubStoreGraph.kt
+++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/GithubStoreGraph.kt
@@ -15,7 +15,9 @@ sealed interface GithubStoreGraph {
@Serializable
data class DetailsScreen(
- val repositoryId: Long
+ val repositoryId: Long = -1L,
+ val owner: String = "",
+ val repo: String = ""
) : GithubStoreGraph
@Serializable
diff --git a/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt b/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt
index 8b819772..d88cbad3 100644
--- a/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt
+++ b/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt
@@ -1,20 +1,54 @@
package zed.rainxch.githubstore
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import org.jetbrains.compose.resources.painterResource
import zed.rainxch.githubstore.app.di.initKoin
import githubstore.composeapp.generated.resources.Res
import githubstore.composeapp.generated.resources.app_icon
+import java.awt.Desktop
+import kotlin.system.exitProcess
-fun main() = application {
+fun main(args: Array) {
+ val deepLinkArg = args.firstOrNull()
+
+ if (deepLinkArg != null && DesktopDeepLink.tryForwardToRunningInstance(deepLinkArg)) {
+ exitProcess(0)
+ }
+
+ DesktopDeepLink.registerUriSchemeIfNeeded()
initKoin()
- Window(
- onCloseRequest = ::exitApplication,
- title = "GitHub Store",
- icon = painterResource(Res.drawable.app_icon)
- ) {
- App()
+ application {
+
+ var deepLinkUri by mutableStateOf(deepLinkArg)
+
+ LaunchedEffect(Unit) {
+ DesktopDeepLink.startInstanceListener { uri ->
+ deepLinkUri = uri
+ }
+ }
+
+ if (Desktop.isDesktopSupported()) {
+ Desktop.getDesktop().let { desktop ->
+ if (desktop.isSupported(Desktop.Action.APP_OPEN_URI)) {
+ desktop.setOpenURIHandler { event ->
+ deepLinkUri = event.uri.toString()
+ }
+ }
+ }
+ }
+
+ Window(
+ onCloseRequest = ::exitApplication,
+ title = "GitHub Store",
+ icon = painterResource(Res.drawable.app_icon)
+ ) {
+ App(deepLinkUri = deepLinkUri)
+ }
}
-}
\ No newline at end of file
+}
diff --git a/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopDeepLink.kt b/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopDeepLink.kt
new file mode 100644
index 00000000..1e131e09
--- /dev/null
+++ b/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopDeepLink.kt
@@ -0,0 +1,167 @@
+package zed.rainxch.githubstore
+
+import java.io.BufferedReader
+import java.io.File
+import java.io.InputStreamReader
+import java.io.PrintWriter
+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
+ private const val SCHEME = "githubstore"
+ private const val DESKTOP_FILE_NAME = "github-store-deeplink"
+
+ /**
+ * On Windows and Linux, ensure the `githubstore://` protocol is registered.
+ * - Windows: Writes to HKCU registry.
+ * - Linux: Creates a `.desktop` file and registers via `xdg-mime`.
+ * No-op on macOS (handled via Info.plist in the packaged .app).
+ */
+ fun registerUriSchemeIfNeeded() {
+ when {
+ isWindows() -> registerWindows()
+ isLinux() -> registerLinux()
+ }
+ }
+
+ private fun registerWindows() {
+ val checkResult = runCommand(
+ "reg", "query", "HKCU\\SOFTWARE\\Classes\\$SCHEME", "/ve"
+ )
+ if (checkResult != null && checkResult.contains("URL:")) return
+
+ val exePath = resolveExePath() ?: return
+
+ runCommand(
+ "reg", "add", "HKCU\\SOFTWARE\\Classes\\$SCHEME",
+ "/ve", "/d", "URL:GitHub Store Protocol", "/f"
+ )
+ runCommand(
+ "reg", "add", "HKCU\\SOFTWARE\\Classes\\$SCHEME",
+ "/v", "URL Protocol", "/d", "", "/f"
+ )
+ runCommand(
+ "reg", "add", "HKCU\\SOFTWARE\\Classes\\$SCHEME\\DefaultIcon",
+ "/ve", "/d", "\"$exePath\",1", "/f"
+ )
+ runCommand(
+ "reg", "add", "HKCU\\SOFTWARE\\Classes\\$SCHEME\\shell\\open\\command",
+ "/ve", "/d", "\"$exePath\" \"%1\"", "/f"
+ )
+ }
+
+ private fun registerLinux() {
+ 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
+
+ appsDir.mkdirs()
+
+ desktopFile.writeText(
+ """
+ [Desktop Entry]
+ Type=Application
+ Name=GitHub Store
+ Exec="$exePath" %u
+ Terminal=false
+ MimeType=x-scheme-handler/$SCHEME;
+ NoDisplay=true
+ """.trimIndent()
+ )
+
+ // Register as the default handler for githubstore:// URIs
+ runCommand("xdg-mime", "default", "$DESKTOP_FILE_NAME.desktop", "x-scheme-handler/$SCHEME")
+ }
+
+ /**
+ * Try to forward a deep link URI to an already-running instance.
+ * @return `true` if the URI was forwarded (this instance should exit),
+ * `false` if no existing instance is running.
+ */
+ fun tryForwardToRunningInstance(uri: String): Boolean {
+ return try {
+ Socket("127.0.0.1", SINGLE_INSTANCE_PORT).use { socket ->
+ PrintWriter(socket.getOutputStream(), true).println(uri)
+ }
+ true
+ } catch (_: Exception) {
+ false
+ }
+ }
+
+ /**
+ * Start listening for URIs forwarded from new instances.
+ * Calls [onUri] on the main thread when a URI is received.
+ */
+ fun startInstanceListener(onUri: (String) -> Unit) {
+ val thread = Thread({
+ try {
+ val server = ServerSocket(SINGLE_INSTANCE_PORT, 50, InetAddress.getLoopbackAddress())
+ while (true) {
+ val client = server.accept()
+ try {
+ val reader = BufferedReader(InputStreamReader(client.getInputStream()))
+ val uri = reader.readLine()
+ if (!uri.isNullOrBlank()) {
+ onUri(uri.trim())
+ }
+ } catch (_: Exception) {
+ } finally {
+ client.close()
+ }
+ }
+ } catch (_: Exception) {
+ }
+ }, "DeepLinkListener")
+ thread.isDaemon = true
+ thread.start()
+ }
+
+ private fun isWindows(): Boolean {
+ return System.getProperty("os.name")?.lowercase()?.contains("win") == true
+ }
+
+ private fun isLinux(): Boolean {
+ return System.getProperty("os.name")?.lowercase()?.contains("linux") == true
+ }
+
+ private fun resolveExePath(): String? {
+ return try {
+ ProcessHandle.current().info().command().orElse(null)
+ } catch (_: Exception) {
+ null
+ }
+ }
+
+ private fun runCommand(vararg cmd: String): String? {
+ return try {
+ val process = ProcessBuilder(*cmd)
+ .redirectErrorStream(true)
+ .start()
+ val output = process.inputStream.bufferedReader().readText()
+ process.waitFor()
+ output
+ } catch (_: Exception) {
+ null
+ }
+ }
+}
diff --git a/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml b/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml
index 5e70a3c1..8a9b1803 100644
--- a/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml
+++ b/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml
@@ -324,4 +324,16 @@
%1$dM
%1$dk
+
+ এইমাত্র প্রকাশিত
+ %1$d ঘণ্টা আগে প্রকাশিত
+ গতকাল প্রকাশিত
+ %1$d দিন আগে প্রকাশিত
+ %1$s তারিখে প্রকাশিত
+
+ হোম
+ অনুসন্ধান
+ অ্যাপস
+ প্রোফাইল
+
diff --git a/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml b/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml
index 44859fbd..38c95cd8 100644
--- a/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml
+++ b/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml
@@ -271,4 +271,16 @@
%1$dM
%1$dk
+
+ Publicado ahora mismo
+ Publicado hace %1$d hora(s)
+ Publicado ayer
+ Publicado hace %1$d día(s)
+ Publicado el %1$s
+
+ Inicio
+ Buscar
+ Aplicaciones
+ Perfil
+
\ No newline at end of file
diff --git a/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml b/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml
index fa578b8c..a3295c56 100644
--- a/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml
+++ b/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml
@@ -271,4 +271,16 @@
%1$dM
%1$dk
+
+ Publié à l’instant
+ Publié il y a %1$d heure(s)
+ Publié hier
+ Publié il y a %1$d jour(s)
+ Publié le %1$s
+
+ Accueil
+ Rechercher
+ Applications
+ Profil
+
\ No newline at end of file
diff --git a/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml b/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml
index 503abded..80a27c0f 100644
--- a/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml
+++ b/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml
@@ -322,4 +322,15 @@
%1$dM
%1$dk
+
+ अभी-अभी जारी किया गया
+ %1$d घंटे पहले जारी किया गया
+ कल जारी किया गया
+ %1$d दिन पहले जारी किया गया
+ %1$s को जारी किया गया
+
+ होम
+ खोज
+ ऐप्स
+ प्रोफ़ाइल
diff --git a/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml b/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml
index b5d9822d..d7eefba2 100644
--- a/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml
+++ b/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml
@@ -320,4 +320,16 @@
%1$dM
%1$dk
+
+ Pubblicato ora
+ Pubblicato %1$d ora/e fa
+ Pubblicato ieri
+ Pubblicato %1$d giorno/i fa
+ Pubblicato il %1$s
+
+ Home
+ Cerca
+ App
+ Profilo
+
\ No newline at end of file
diff --git a/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml b/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml
index 142995f9..4bba002b 100644
--- a/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml
+++ b/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml
@@ -271,4 +271,16 @@
%1$dM
%1$dk
+
+ たった今リリース
+ %1$d時間前にリリース
+ 昨日リリース
+ %1$d日前にリリース
+ %1$sにリリース
+
+ ホーム
+ 検索
+ アプリ
+ プロフィール
+
\ No newline at end of file
diff --git a/core/presentation/src/commonMain/composeResources/values-kr/strings-kr.xml b/core/presentation/src/commonMain/composeResources/values-kr/strings-kr.xml
index 274c26a6..0e4b2e07 100644
--- a/core/presentation/src/commonMain/composeResources/values-kr/strings-kr.xml
+++ b/core/presentation/src/commonMain/composeResources/values-kr/strings-kr.xml
@@ -322,4 +322,16 @@
%1$dM
%1$dk
+
+ 방금 출시됨
+ %1$d시간 전에 출시됨
+ 어제 출시됨
+ %1$d일 전에 출시됨
+ %1$s에 출시됨
+
+ 홈
+ 검색
+ 앱
+ 프로필
+
\ No newline at end of file
diff --git a/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml b/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml
index b1e0b80d..a4ba0270 100644
--- a/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml
+++ b/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml
@@ -288,4 +288,15 @@
%1$dM
%1$dk
+ Wydano przed chwilą
+ Wydano %1$d godzin(y) temu
+ Wydano wczoraj
+ Wydano %1$d dzień/dni temu
+ Wydano %1$s
+
+ Strona główna
+ Szukaj
+ Aplikacje
+ Profil
+
\ No newline at end of file
diff --git a/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml b/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml
index 2f0f326e..17375c6c 100644
--- a/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml
+++ b/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml
@@ -289,4 +289,16 @@
%1$dM
%1$dk
+
+ Опубликовано только что
+ Опубликовано %1$d час(ов) назад
+ Опубликовано вчера
+ Опубликовано %1$d день(дней) назад
+ Опубликовано %1$s
+
+ Главная
+ Поиск
+ Приложения
+ Профиль
+
\ No newline at end of file
diff --git a/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml b/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml
index 9b3761b5..9fd9fe9f 100644
--- a/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml
+++ b/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml
@@ -322,4 +322,15 @@
%1$dM
%1$dk
+ Az önce yayınlandı
+ %1$d saat önce yayınlandı
+ Dün yayınlandı
+ %1$d gün önce yayınlandı
+ %1$s tarihinde yayınlandı
+
+ Ana Sayfa
+ Ara
+ Uygulamalar
+ Profil
+
diff --git a/core/presentation/src/commonMain/composeResources/values-zh-rCN/strings-zh-rCN.xml b/core/presentation/src/commonMain/composeResources/values-zh-rCN/strings-zh-rCN.xml
index c82796b8..21c88168 100644
--- a/core/presentation/src/commonMain/composeResources/values-zh-rCN/strings-zh-rCN.xml
+++ b/core/presentation/src/commonMain/composeResources/values-zh-rCN/strings-zh-rCN.xml
@@ -272,4 +272,16 @@
%1$dM
%1$dk
+
+ 刚刚发布
+ %1$d 小时前发布
+ 昨天发布
+ %1$d 天前发布
+ 发布于 %1$s
+
+ 首页
+ 搜索
+ 应用
+ 个人资料
+
\ No newline at end of file
diff --git a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/DetailsRepositoryImpl.kt b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/DetailsRepositoryImpl.kt
index 87d95cdc..1baeb22b 100644
--- a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/DetailsRepositoryImpl.kt
+++ b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/DetailsRepositoryImpl.kt
@@ -34,33 +34,43 @@ class DetailsRepositoryImpl(
private val readmeHelper = ReadmeLocalizationHelper(localizationManager)
+ private fun RepoByIdNetwork.toGithubRepoSummary(): GithubRepoSummary {
+ return GithubRepoSummary(
+ id = id,
+ name = name,
+ fullName = fullName,
+ owner = GithubUser(
+ id = owner.id,
+ login = owner.login,
+ avatarUrl = owner.avatarUrl,
+ htmlUrl = owner.htmlUrl
+ ),
+ description = description,
+ htmlUrl = htmlUrl,
+ stargazersCount = stars,
+ forksCount = forks,
+ language = language,
+ topics = topics,
+ releasesUrl = "https://api.github.com/repos/${owner.login}/${name}/releases{/id}",
+ updatedAt = updatedAt,
+ defaultBranch = defaultBranch
+ )
+ }
+
override suspend fun getRepositoryById(id: Long): GithubRepoSummary {
- val repo = httpClient.executeRequest {
+ return httpClient.executeRequest {
get("/repositories/$id") {
header(HttpHeaders.Accept, "application/vnd.github+json")
}
- }.getOrThrow()
+ }.getOrThrow().toGithubRepoSummary()
+ }
- return GithubRepoSummary(
- id = repo.id,
- name = repo.name,
- fullName = repo.fullName,
- owner = GithubUser(
- id = repo.owner.id,
- login = repo.owner.login,
- avatarUrl = repo.owner.avatarUrl,
- htmlUrl = repo.owner.htmlUrl
- ),
- description = repo.description,
- htmlUrl = repo.htmlUrl,
- stargazersCount = repo.stars,
- forksCount = repo.forks,
- language = repo.language,
- topics = repo.topics,
- releasesUrl = "https://api.github.com/repos/${repo.owner.login}/${repo.name}/releases{/id}",
- updatedAt = repo.updatedAt,
- defaultBranch = repo.defaultBranch
- )
+ override suspend fun getRepositoryByOwnerAndName(owner: String, name: String): GithubRepoSummary {
+ return httpClient.executeRequest {
+ get("/repos/$owner/$name") {
+ header(HttpHeaders.Accept, "application/vnd.github+json")
+ }
+ }.getOrThrow().toGithubRepoSummary()
}
override suspend fun getLatestPublishedRelease(
diff --git a/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/repository/DetailsRepository.kt b/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/repository/DetailsRepository.kt
index e784e653..1399f77a 100644
--- a/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/repository/DetailsRepository.kt
+++ b/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/repository/DetailsRepository.kt
@@ -12,6 +12,8 @@ typealias LanguageCode = String
interface DetailsRepository {
suspend fun getRepositoryById(id: Long): GithubRepoSummary
+ suspend fun getRepositoryByOwnerAndName(owner: String, name: String): GithubRepoSummary
+
suspend fun getLatestPublishedRelease(
owner: String,
repo: String,
diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt
index 6f52cafc..3359ff58 100644
--- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt
+++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt
@@ -44,6 +44,8 @@ import kotlin.time.ExperimentalTime
class DetailsViewModel(
private val repositoryId: Long,
+ private val ownerParam: String,
+ private val repoParam: String,
private val detailsRepository: DetailsRepository,
private val downloader: Downloader,
private val installer: Installer,
@@ -93,7 +95,11 @@ class DetailsViewModel(
logger.warn("Sync had issues but continuing: ${syncResult.exceptionOrNull()?.message}")
}
- val repo = detailsRepository.getRepositoryById(repositoryId)
+ val repo = if (ownerParam.isNotEmpty() && repoParam.isNotEmpty()) {
+ detailsRepository.getRepositoryByOwnerAndName(ownerParam, repoParam)
+ } else {
+ detailsRepository.getRepositoryById(repositoryId)
+ }
val isFavoriteDeferred = async {
try {
favouritesRepository.isFavoriteSync(repo.id)
diff --git a/feature/dev-profile/domain/src/commonMain/kotlin/zed/rainxch/devprofile/domain/model/RepoFilterType.kt b/feature/dev-profile/domain/src/commonMain/kotlin/zed/rainxch/devprofile/domain/model/RepoFilterType.kt
index 5e30caed..883a6f47 100644
--- a/feature/dev-profile/domain/src/commonMain/kotlin/zed/rainxch/devprofile/domain/model/RepoFilterType.kt
+++ b/feature/dev-profile/domain/src/commonMain/kotlin/zed/rainxch/devprofile/domain/model/RepoFilterType.kt
@@ -1,7 +1,6 @@
package zed.rainxch.devprofile.domain.model
enum class RepoFilterType {
- ALL,
WITH_RELEASES,
INSTALLED,
FAVORITES
diff --git a/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/DeveloperProfileRoot.kt b/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/DeveloperProfileRoot.kt
index 07c9ee85..6209543f 100644
--- a/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/DeveloperProfileRoot.kt
+++ b/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/DeveloperProfileRoot.kt
@@ -224,7 +224,6 @@ private fun EmptyReposContent(
modifier: Modifier = Modifier
) {
val message = when (filter) {
- RepoFilterType.ALL -> stringResource(Res.string.no_repositories_found)
RepoFilterType.WITH_RELEASES -> stringResource(Res.string.no_repos_with_releases)
RepoFilterType.INSTALLED -> stringResource(Res.string.no_installed_repos)
RepoFilterType.FAVORITES -> stringResource(Res.string.no_favorite_repos)
diff --git a/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/DeveloperProfileState.kt b/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/DeveloperProfileState.kt
index 10cfc6a4..2b6de1cd 100644
--- a/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/DeveloperProfileState.kt
+++ b/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/DeveloperProfileState.kt
@@ -15,7 +15,7 @@ data class DeveloperProfileState(
val isLoading: Boolean = false,
val isLoadingRepos: Boolean = false,
val errorMessage: String? = null,
- val currentFilter: RepoFilterType = RepoFilterType.ALL,
+ val currentFilter: RepoFilterType = RepoFilterType.WITH_RELEASES,
val currentSort: RepoSortType = RepoSortType.UPDATED,
val searchQuery: String = ""
)
\ No newline at end of file
diff --git a/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/DeveloperProfileViewModel.kt b/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/DeveloperProfileViewModel.kt
index 1317525f..f8c5f895 100644
--- a/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/DeveloperProfileViewModel.kt
+++ b/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/DeveloperProfileViewModel.kt
@@ -135,7 +135,6 @@ class DeveloperProfileViewModel(
}
filtered = when (currentState.currentFilter) {
- RepoFilterType.ALL -> filtered
RepoFilterType.WITH_RELEASES -> filtered.filter { it.hasInstallableAssets }
.toImmutableList()
diff --git a/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/components/FilterSortControls.kt b/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/components/FilterSortControls.kt
index 613554a4..81effad5 100644
--- a/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/components/FilterSortControls.kt
+++ b/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/components/FilterSortControls.kt
@@ -231,7 +231,6 @@ private fun SortMenu(
@Composable
private fun RepoFilterType.displayName(): String {
return when (this) {
- RepoFilterType.ALL -> stringResource(Res.string.filter_all)
RepoFilterType.WITH_RELEASES -> stringResource(Res.string.filter_with_releases)
RepoFilterType.INSTALLED -> stringResource(Res.string.filter_installed)
RepoFilterType.FAVORITES -> stringResource(Res.string.filter_favorites)
diff --git a/gradle.properties b/gradle.properties
index 6f8e6ea6..3192d193 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -9,4 +9,6 @@ org.gradle.caching=true
#Android
android.nonTransitiveRClass=true
-android.useAndroidX=true
\ No newline at end of file
+android.useAndroidX=true
+
+compose.desktop.packaging.checkJdkVendor=false
\ No newline at end of file