From 859ea92b80eae9fdc3a0c50df454abfc1d640b3b Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Sat, 14 Feb 2026 12:42:20 +0500 Subject: [PATCH 1/8] feat(app): Implement deep linking for repository details This commit introduces support for deep linking, allowing users to open repository detail screens directly from external URLs. The app now handles custom schemes and standard GitHub repository URLs. To support opening repositories by owner and name (from a deep link) instead of just by ID, the data and presentation layers have been updated to fetch repository data using this new information. - **feat(deeplink)**: Added `DeepLinkParser` to handle various URI formats: - `githubstore://repo/{owner}/{repo}` - `https://github.com/{owner}/{repo}` - `https://github-store.org/app/{owner}/{repo}` - **feat(android)**: Configured `AndroidManifest.xml` with intent filters for the supported deep link schemes and hosts, including `autoVerify` for app links. - **feat(android, desktop)**: Updated `MainActivity` and `DesktopApp` to receive and process incoming deep link URIs. - **feat(details)**: Added `getRepositoryByOwnerAndName` to the `DetailsRepository` and `DetailsViewModel` to fetch repository data via owner/name, complementing the existing `getRepositoryById` method. - **refactor(navigation)**: Modified the `DetailsScreen` navigation destination to accept `owner` and `repo` parameters alongside `repositoryId`. --- .../src/androidMain/AndroidManifest.xml | 42 ++++++++++- .../zed/rainxch/githubstore/MainActivity.kt | 25 ++++++- .../kotlin/zed/rainxch/githubstore/Main.kt | 21 +++++- .../app/deeplink/DeepLinkParser.kt | 71 +++++++++++++++++++ .../app/navigation/AppNavigation.kt | 2 +- .../app/navigation/GithubStoreGraph.kt | 4 +- .../zed/rainxch/githubstore/DesktopApp.kt | 8 ++- .../data/repository/DetailsRepositoryImpl.kt | 54 ++++++++------ .../domain/repository/DetailsRepository.kt | 2 + .../details/presentation/DetailsViewModel.kt | 8 ++- 10 files changed, 206 insertions(+), 31 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/deeplink/DeepLinkParser.kt diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index cc77a1e9..694dd83d 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..5c59218c 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,28 @@ 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, 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..bf3bdc1a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/deeplink/DeepLinkParser.kt @@ -0,0 +1,71 @@ +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 githubExcludedPaths = setOf( + "settings", "notifications", "explore", "marketplace", + "login", "signup", "join", "new", "organizations", + "topics", "trending", "collections", "sponsors", + "features", "security", "pricing", "enterprise", + "about", "customer-stories", "readme", "team" + ) + + /** + * Parses a URI string into a [DeepLinkDestination]. + * + * Supported formats: + * - `https://github.com/{owner}/{repo}` + * - `https://github.com/{owner}/{repo}/...` (any sub-path, extra segments ignored) + * - `https://github-store.org/app/{owner}/{repo}` + * - `githubstore://repo/{owner}/{repo}` + */ + fun parse(uri: String): DeepLinkDestination { + val trimmed = uri.trim() + + // Handle githubstore://repo/{owner}/{repo} + if (trimmed.startsWith("githubstore://repo/")) { + val path = trimmed.removePrefix("githubstore://repo/") + return parseOwnerRepo(path) + } + + // Handle https://github.com/{owner}/{repo} + val githubPattern = Regex("^https?://github\\.com/([^/]+)/([^/?#]+)") + githubPattern.find(trimmed)?.let { match -> + val owner = match.groupValues[1] + val repo = match.groupValues[2] + if (isValidOwnerRepo(owner, repo)) { + return DeepLinkDestination.Repository(owner, repo) + } + } + + // Handle https://github-store.org/app/{owner}/{repo} + val storePattern = Regex("^https?://github-store\\.org/app/([^/]+)/([^/?#]+)") + storePattern.find(trimmed)?.let { match -> + val owner = match.groupValues[1] + val repo = match.groupValues[2] + if (owner.isNotEmpty() && repo.isNotEmpty()) { + return DeepLinkDestination.Repository(owner, repo) + } + } + + return DeepLinkDestination.None + } + + private fun parseOwnerRepo(path: String): DeepLinkDestination { + val segments = path.split("/").filter { it.isNotEmpty() } + if (segments.size >= 2 && segments[0].isNotEmpty() && segments[1].isNotEmpty()) { + return DeepLinkDestination.Repository(segments[0], segments[1]) + } + return DeepLinkDestination.None + } + + private fun isValidOwnerRepo(owner: String, repo: String): Boolean { + return owner.isNotEmpty() && repo.isNotEmpty() + && owner.lowercase() !in githubExcludedPaths + } +} 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..315a9412 100644 --- a/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt +++ b/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt @@ -7,14 +7,16 @@ import zed.rainxch.githubstore.app.di.initKoin import githubstore.composeapp.generated.resources.Res import githubstore.composeapp.generated.resources.app_icon -fun main() = application { +fun main(args: Array) = application { initKoin() + val deepLinkUri = args.firstOrNull() + Window( onCloseRequest = ::exitApplication, title = "GitHub Store", icon = painterResource(Res.drawable.app_icon) ) { - App() + App(deepLinkUri = deepLinkUri) } -} \ 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) From 9f18790c4f938b24ddb031e14f1e20ee5060dc8e Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Sat, 14 Feb 2026 12:48:13 +0500 Subject: [PATCH 2/8] refactor(dev-profile): Remove "All" filter and set "With Releases" as default This commit removes the "All" filter option from the developer profile screen and establishes "With Releases" as the new default filter for repositories. - **refactor(domain)**: Removed `ALL` from the `RepoFilterType` enum. - **refactor(presentation)**: Updated the initial state in `DeveloperProfileState` to use `WITH_RELEASES` as the `currentFilter`. - **refactor(presentation)**: Removed logic and UI elements related to the "All" filter from the ViewModel, composables, and string resources. --- .../zed/rainxch/devprofile/domain/model/RepoFilterType.kt | 1 - .../zed/rainxch/devprofile/presentation/DeveloperProfileRoot.kt | 1 - .../rainxch/devprofile/presentation/DeveloperProfileState.kt | 2 +- .../devprofile/presentation/DeveloperProfileViewModel.kt | 1 - .../devprofile/presentation/components/FilterSortControls.kt | 1 - 5 files changed, 1 insertion(+), 5 deletions(-) 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) From 7ae63cf874aefc32192b2685ee8beff6cfb6aa91 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Sat, 14 Feb 2026 13:49:07 +0500 Subject: [PATCH 3/8] feat(desktop): Implement deep linking for macOS This commit introduces deep linking functionality for the macOS desktop application. The app now registers and handles the custom `githubstore://` URI scheme. - **feat(desktop)**: Added a URI scheme handler (`setOpenURIHandler`) in `DesktopApp.kt` to process `githubstore://` links on macOS. - **feat(desktop)**: Configured the macOS app bundle (`build.gradle.kts`) by adding `CFBundleURLTypes` to the `Info.plist` to register the custom `githubstore` URL scheme. - **refactor(deeplink)**: Updated the `DeepLinkParser` to support a new web URL format (`https://github-store.org/app/?repo={owner}/{repo}`) using a query parameter, replacing the previous path-based structure. - **fix(android)**: Changed the Android intent filter for the app's website to be a standard deep link, as `autoVerify` is not applicable for this URL structure. --- composeApp/build.gradle.kts | 17 ++++++++++++ .../src/androidMain/AndroidManifest.xml | 4 +-- .../app/deeplink/DeepLinkParser.kt | 27 ++++++++++++------- .../zed/rainxch/githubstore/DesktopApp.kt | 20 +++++++++++++- 4 files changed, 56 insertions(+), 12 deletions(-) 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 694dd83d..2074744d 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -72,8 +72,8 @@ android:scheme="https" /> - - + + 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 index bf3bdc1a..07da3111 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/deeplink/DeepLinkParser.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/deeplink/DeepLinkParser.kt @@ -19,10 +19,10 @@ object DeepLinkParser { * Parses a URI string into a [DeepLinkDestination]. * * Supported formats: + * - `githubstore://repo/{owner}/{repo}` * - `https://github.com/{owner}/{repo}` * - `https://github.com/{owner}/{repo}/...` (any sub-path, extra segments ignored) - * - `https://github-store.org/app/{owner}/{repo}` - * - `githubstore://repo/{owner}/{repo}` + * - `https://github-store.org/app/?repo={owner}/{repo}` */ fun parse(uri: String): DeepLinkDestination { val trimmed = uri.trim() @@ -43,13 +43,12 @@ object DeepLinkParser { } } - // Handle https://github-store.org/app/{owner}/{repo} - val storePattern = Regex("^https?://github-store\\.org/app/([^/]+)/([^/?#]+)") - storePattern.find(trimmed)?.let { match -> - val owner = match.groupValues[1] - val repo = match.groupValues[2] - if (owner.isNotEmpty() && repo.isNotEmpty()) { - return DeepLinkDestination.Repository(owner, repo) + // Handle https://github-store.org/app/?repo={owner}/{repo} + val storeQueryPattern = Regex("^https?://github-store\\.org/app/?(\\?|#)") + if (storeQueryPattern.containsMatchIn(trimmed)) { + val repoParam = extractQueryParam(trimmed, "repo") + if (repoParam != null) { + return parseOwnerRepo(repoParam) } } @@ -68,4 +67,14 @@ object DeepLinkParser { return owner.isNotEmpty() && repo.isNotEmpty() && owner.lowercase() !in githubExcludedPaths } + + private fun extractQueryParam(uri: String, key: String): String? { + val queryStart = uri.indexOf('?') + if (queryStart == -1) return null + val query = uri.substring(queryStart + 1).split('#').first() + return query.split('&') + .map { it.split('=', limit = 2) } + .firstOrNull { it.size == 2 && it[0] == key } + ?.get(1) + } } diff --git a/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt b/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt index 315a9412..d519305c 100644 --- a/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt +++ b/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt @@ -1,16 +1,34 @@ package zed.rainxch.githubstore +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 java.net.URI fun main(args: Array) = application { initKoin() - val deepLinkUri = args.firstOrNull() + // Deep link state — can come from CLI args or macOS open-url event + var deepLinkUri by mutableStateOf(args.firstOrNull()) + + // Register macOS URI scheme handler (githubstore://) + // When the packaged .app is opened via a URL, macOS delivers it here + 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, From 1314f0bad4d9a70ae3ac17897214c6d28a8bb690 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Sat, 14 Feb 2026 13:49:21 +0500 Subject: [PATCH 4/8] refactor(dialog): Improve null safety for RateLimitDialog This commit enhances the null safety of the `RateLimitDialog`. The `rateLimitInfo` parameter is now non-nullable, preventing potential null pointer exceptions within the composable. The call site in `Main.kt` has been updated to handle the nullable `state.rateLimitInfo` with a `let` block, ensuring the dialog is only composed when the rate limit data is available. - **refactor(components)**: Changed `RateLimitDialog`'s `rateLimitInfo` parameter from nullable (`RateLimitInfo?`) to non-nullable (`RateLimitInfo`). - **fix(app)**: Wrapped the `RateLimitDialog` call in `Main.kt` within a null check (`state.rateLimitInfo?.let`) to ensure it's only shown when `rateLimitInfo` is not null. - **chore(preview)**: Updated the `RateLimitDialogPreview` to provide a non-null `RateLimitInfo` instance. --- .../kotlin/zed/rainxch/githubstore/Main.kt | 30 +++++++++++-------- .../app/components/RateLimitDialog.kt | 17 +++++++---- 2 files changed, 28 insertions(+), 19 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/Main.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/Main.kt index 5c59218c..e114987c 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/Main.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/Main.kt @@ -37,7 +37,9 @@ fun App(deepLinkUri: String? = null) { ) ) } - DeepLinkDestination.None -> { /* ignore unrecognized deep links */ } + + DeepLinkDestination.None -> { /* ignore unrecognized deep links */ + } } } } @@ -50,19 +52,21 @@ fun App(deepLinkUri: String? = null) { ) { 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) + 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) - } - ) + 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 = { From cdc0f61f040ca076f41c14922e224a0b0021d3e6 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Sat, 14 Feb 2026 13:54:41 +0500 Subject: [PATCH 5/8] i18n: Add translations for release dates and bottom navigation This commit introduces localized strings for relative release timestamps and bottom navigation titles across multiple languages. - **feat(i18n)**: Added translations for relative time expressions like "just now," "yesterday," and "x days ago." - **feat(i18n)**: Added translations for the bottom navigation bar titles: Home, Search, Apps, and Profile. - **chore(i18n)**: Provided translations for the following languages: Bengali (`bn`), Spanish (`es`), French (`fr`), Italian (`it`), Japanese (`ja`), Korean (`kr`), Polish (`pl`), Russian (`ru`), Turkish (`tr`), and Simplified Chinese (`zh-rCN`). --- .../composeResources/values-bn/strings-bn.xml | 12 ++++++++++++ .../composeResources/values-es/strings-es.xml | 12 ++++++++++++ .../composeResources/values-fr/strings-fr.xml | 12 ++++++++++++ .../composeResources/values-it/strings-it.xml | 12 ++++++++++++ .../composeResources/values-ja/strings-ja.xml | 12 ++++++++++++ .../composeResources/values-kr/strings-kr.xml | 12 ++++++++++++ .../composeResources/values-pl/strings-pl.xml | 11 +++++++++++ .../composeResources/values-ru/strings-ru.xml | 12 ++++++++++++ .../composeResources/values-tr/strings-tr.xml | 11 +++++++++++ .../values-zh-rCN/strings-zh-rCN.xml | 12 ++++++++++++ 10 files changed, 118 insertions(+) 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-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 From ace8819b684405ffef0cac9ca8f525986d64e085 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Mon, 16 Feb 2026 17:21:28 +0500 Subject: [PATCH 6/8] feat(desktop): Implement deep linking for Windows and Linux This commit adds deep linking support for Windows and Linux, along with a more robust single-instance and security model for all desktop platforms. The app now handles the custom `githubstore://` URI scheme across desktop environments. - **feat(desktop)**: Added `DesktopDeepLink.kt` to manage URI scheme registration and single-instance logic. - **Windows**: Registers the `githubstore://` protocol in the registry (`HKCU\\SOFTWARE\\Classes`). - **Linux**: Creates a `.desktop` file and registers the scheme via `xdg-mime`. - **feat(desktop)**: Implemented single-instance forwarding using a local socket. When a second instance is launched with a deep link, it forwards the URI to the primary instance and exits. - **refactor(deeplink)**: Hardened `DeepLinkParser` with stricter validation to prevent path traversal and injection attacks. It now rejects invalid characters, forbidden patterns (like `..`), and reserved GitHub path names. - **refactor(desktop)**: Updated `DesktopApp.kt` to integrate the new single-instance handling and URI registration logic. - **chore(build)**: Disabled the Compose Desktop packaging JDK vendor check (`compose.desktop.packaging.checkJdkVendor=false`) in `gradle.properties` to improve build compatibility. --- .../app/deeplink/DeepLinkParser.kt | 195 +++++++++++++----- .../zed/rainxch/githubstore/DesktopApp.kt | 49 +++-- .../rainxch/githubstore/DesktopDeepLink.kt | 166 +++++++++++++++ gradle.properties | 4 +- 4 files changed, 345 insertions(+), 69 deletions(-) create mode 100644 composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopDeepLink.kt 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 index 07da3111..4d2327db 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/deeplink/DeepLinkParser.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/deeplink/DeepLinkParser.kt @@ -6,75 +6,172 @@ sealed interface DeepLinkDestination { } object DeepLinkParser { + private val INVALID_CHARS = setOf('/', '\\', '?', '#', '@', ':', '*', '"', '<', '>', '|', '%', '&', '=') - private val githubExcludedPaths = setOf( - "settings", "notifications", "explore", "marketplace", - "login", "signup", "join", "new", "organizations", - "topics", "trending", "collections", "sponsors", - "features", "security", "pricing", "enterprise", - "about", "customer-stories", "readme", "team" + 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" ) - /** - * Parses a URI string into a [DeepLinkDestination]. - * - * Supported formats: - * - `githubstore://repo/{owner}/{repo}` - * - `https://github.com/{owner}/{repo}` - * - `https://github.com/{owner}/{repo}/...` (any sub-path, extra segments ignored) - * - `https://github-store.org/app/?repo={owner}/{repo}` - */ fun parse(uri: String): DeepLinkDestination { - val trimmed = uri.trim() + return when { + uri.startsWith("githubstore://repo/") -> { + val path = uri.removePrefix("githubstore://repo/") + val decoded = urlDecode(path) + parseOwnerRepo(decoded) + } - // Handle githubstore://repo/{owner}/{repo} - if (trimmed.startsWith("githubstore://repo/")) { - val path = trimmed.removePrefix("githubstore://repo/") - return parseOwnerRepo(path) - } + uri.startsWith("https://github.com/") -> { + val path = uri.removePrefix("https://github.com/") + .substringBefore('?') + .substringBefore('#') + val decoded = urlDecode(path) - // Handle https://github.com/{owner}/{repo} - val githubPattern = Regex("^https?://github\\.com/([^/]+)/([^/?#]+)") - githubPattern.find(trimmed)?.let { match -> - val owner = match.groupValues[1] - val repo = match.groupValues[2] - if (isValidOwnerRepo(owner, repo)) { - return DeepLinkDestination.Repository(owner, repo) + 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 } - } - // Handle https://github-store.org/app/?repo={owner}/{repo} - val storeQueryPattern = Regex("^https?://github-store\\.org/app/?(\\?|#)") - if (storeQueryPattern.containsMatchIn(trimmed)) { - val repoParam = extractQueryParam(trimmed, "repo") - if (repoParam != null) { - return parseOwnerRepo(repoParam) + uri.startsWith("https://github-store.org/app/") -> { + extractQueryParam(uri, "repo")?.let { encodedRepoParam -> + val decoded = urlDecode(encodedRepoParam) + parseOwnerRepo(decoded) + } ?: DeepLinkDestination.None } + + else -> DeepLinkDestination.None } + } - return 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 segments = path.split("/").filter { it.isNotEmpty() } - if (segments.size >= 2 && segments[0].isNotEmpty() && segments[1].isNotEmpty()) { - return DeepLinkDestination.Repository(segments[0], segments[1]) + 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 } - return DeepLinkDestination.None } - private fun isValidOwnerRepo(owner: String, repo: String): Boolean { - return owner.isNotEmpty() && repo.isNotEmpty() - && owner.lowercase() !in githubExcludedPaths + /** + * 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 query = uri.substring(queryStart + 1).split('#').first() - return query.split('&') - .map { it.split('=', limit = 2) } - .firstOrNull { it.size == 2 && it[0] == key } - ?.get(1) + + val queryString = uri.substring(queryStart + 1) + val params = queryString.split('&') + + for (param in params) { + val keyValue = param.split('=') + if (keyValue.size == 2 && keyValue[0] == key) { + return keyValue[1] + } + } + return null } -} +} \ No newline at end of file diff --git a/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt b/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt index d519305c..d5f72299 100644 --- a/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt +++ b/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt @@ -10,31 +10,42 @@ 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 java.net.URI +import kotlin.system.exitProcess -fun main(args: Array) = application { - initKoin() +fun main(args: Array) { + val deepLinkArg = args.firstOrNull() - // Deep link state — can come from CLI args or macOS open-url event - var deepLinkUri by mutableStateOf(args.firstOrNull()) + if (deepLinkArg != null && DesktopDeepLink.tryForwardToRunningInstance(deepLinkArg)) { + exitProcess(0) + } + + DesktopDeepLink.registerUriSchemeIfNeeded() + + application { + initKoin() + + var deepLinkUri by mutableStateOf(deepLinkArg) - // Register macOS URI scheme handler (githubstore://) - // When the packaged .app is opened via a URL, macOS delivers it here - if (Desktop.isDesktopSupported()) { - Desktop.getDesktop().let { desktop -> - if (desktop.isSupported(Desktop.Action.APP_OPEN_URI)) { - desktop.setOpenURIHandler { event -> - deepLinkUri = event.uri.toString() + 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) + Window( + onCloseRequest = ::exitApplication, + title = "GitHub Store", + icon = painterResource(Res.drawable.app_icon) + ) { + App(deepLinkUri = deepLinkUri) + } } } 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..06c8722c --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopDeepLink.kt @@ -0,0 +1,166 @@ +package zed.rainxch.githubstore + +import java.io.BufferedReader +import java.io.File +import java.io.InputStreamReader +import java.io.PrintWriter +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) + 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/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 From 5e0c9d00ab33d187382d2975a25b9d77bfcc4df4 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Mon, 16 Feb 2026 20:45:17 +0500 Subject: [PATCH 7/8] fix(desktop): Improve deep link handling and security This commit refines the desktop deep linking implementation by enhancing its security and robustness. The single-instance listener now binds the `ServerSocket` exclusively to the loopback address (`127.0.0.1`), preventing it from being exposed to the local network. This measure strengthens security by ensuring that only processes running on the same machine can send deep link URIs to the application. Additionally, the deep link parser has been corrected to properly handle query parameter values that contain an `=` character. - **fix(desktop)**: Bound the `ServerSocket` in `DesktopDeepLink` to `InetAddress.getLoopbackAddress()` to prevent external network access. - **fix(deeplink)**: Updated `DeepLinkParser` to use `split('=', limit = 2)` to correctly parse query parameters that may contain an equals sign in their value. - **refactor(desktop)**: Wrapped the deep link instance listener in `DesktopApp.kt` within a `LaunchedEffect` to align with Compose's lifecycle management practices. --- .../rainxch/githubstore/app/deeplink/DeepLinkParser.kt | 2 +- .../jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt | 9 ++++++--- .../kotlin/zed/rainxch/githubstore/DesktopDeepLink.kt | 3 ++- 3 files changed, 9 insertions(+), 5 deletions(-) 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 index 4d2327db..0a10a064 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/deeplink/DeepLinkParser.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/deeplink/DeepLinkParser.kt @@ -167,7 +167,7 @@ object DeepLinkParser { val params = queryString.split('&') for (param in params) { - val keyValue = param.split('=') + val keyValue = param.split('=', limit = 2) if (keyValue.size == 2 && keyValue[0] == key) { return keyValue[1] } diff --git a/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt b/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt index d5f72299..d88cbad3 100644 --- a/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt +++ b/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt @@ -1,5 +1,6 @@ package zed.rainxch.githubstore +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -20,14 +21,16 @@ fun main(args: Array) { } DesktopDeepLink.registerUriSchemeIfNeeded() + initKoin() application { - initKoin() var deepLinkUri by mutableStateOf(deepLinkArg) - DesktopDeepLink.startInstanceListener { uri -> - deepLinkUri = uri + LaunchedEffect(Unit) { + DesktopDeepLink.startInstanceListener { uri -> + deepLinkUri = uri + } } if (Desktop.isDesktopSupported()) { diff --git a/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopDeepLink.kt b/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopDeepLink.kt index 06c8722c..1e131e09 100644 --- a/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopDeepLink.kt +++ b/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopDeepLink.kt @@ -4,6 +4,7 @@ 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 @@ -114,7 +115,7 @@ object DesktopDeepLink { fun startInstanceListener(onUri: (String) -> Unit) { val thread = Thread({ try { - val server = ServerSocket(SINGLE_INSTANCE_PORT) + val server = ServerSocket(SINGLE_INSTANCE_PORT, 50, InetAddress.getLoopbackAddress()) while (true) { val client = server.accept() try { From aae55942614bb2290055f99fe52aa711a642c7c1 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Mon, 16 Feb 2026 20:56:23 +0500 Subject: [PATCH 8/8] i18n: Add Hindi translations This commit adds Hindi (`hi`) translations for relative release timestamps and bottom navigation titles. - **feat(i18n)**: Added translations for relative release date strings (e.g., "just now", "yesterday", "x days ago"). - **feat(i18n)**: Added translations for the bottom navigation bar titles: Home, Search, Apps, and Profile. --- .../composeResources/values-hi/strings-hi.xml | 11 +++++++++++ 1 file changed, 11 insertions(+) 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 को जारी किया गया + + होम + खोज + ऐप्स + प्रोफ़ाइल