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