Skip to content

feat(app): Implement deep linking for repository details#246

Open
rainxchzed wants to merge 5 commits intomainfrom
deeplink
Open

feat(app): Implement deep linking for repository details#246
rainxchzed wants to merge 5 commits intomainfrom
deeplink

Conversation

@rainxchzed
Copy link
Owner

@rainxchzed rainxchzed commented Feb 14, 2026

Summary by CodeRabbit

  • New Features

    • Deep link support across macOS, Android and desktop (multiple URL formats) to open repositories and app pages directly.
    • Direct repository lookup by owner/name and navigation to repository details via links.
  • Improvements

    • Cleaner rate limit dialog rendering and display.
    • Developer profile filter default changed to show releases first.
    • Added new localized strings for multiple languages (release times and bottom navigation).

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`.
… 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.
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.
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.
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 14, 2026

Walkthrough

Adds cross-platform deep-link support (Android intent-filters, macOS URL scheme, desktop URI handler) and a DeepLinkParser, routes deep links to DetailsScreen via owner/repo lookups, updates navigation and viewmodels, changes RateLimitDialog API, and removes the RepoFilterType.ALL enum entry.

Changes

Cohort / File(s) Summary
Android deep links
composeApp/src/androidMain/AndroidManifest.xml, composeApp/src/androidMain/kotlin/.../MainActivity.kt
Added android:launchMode="singleTask" and intent-filters for githubstore://, https://github.com/..., and https://github-store.org/.... MainActivity captures/passes deep-link URIs into the App composable via state and onNewIntent handling.
macOS / Gradle plist
composeApp/build.gradle.kts
Added infoPlist/extraKeysRawXml under macOS compose target to register CFBundleURLTypes for the githubstore URL scheme.
Desktop (JVM) deep links
composeApp/src/jvmMain/kotlin/.../DesktopApp.kt
main now accepts args, initializes deepLinkUri from args, and registers a Desktop URI handler to update deepLinkUri when a URI is opened; App called with deepLinkUri.
Deep link parsing utility
composeApp/src/commonMain/kotlin/.../app/deeplink/DeepLinkParser.kt
New DeepLinkDestination sealed type and DeepLinkParser.parse(uri: String) handling githubstore://repo/..., https://github.com/..., and https://github-store.org/app/?repo=...; includes validation helpers.
App integration & Main
composeApp/src/commonMain/kotlin/.../Main.kt, composeApp/src/commonMain/kotlin/.../MainActivity.kt
App accepts deepLinkUri parameter; Main and MainActivity updated to set/pass deep link state and to trigger navigation via LaunchedEffect/intent listeners.
Rate limit dialog API
composeApp/src/commonMain/kotlin/.../app/components/RateLimitDialog.kt
rateLimitInfo parameter changed from nullable to non-null; preview updated to supply concrete RateLimitInfo.
Details navigation & viewmodel
composeApp/src/commonMain/kotlin/.../app/navigation/GithubStoreGraph.kt, composeApp/src/commonMain/kotlin/.../app/navigation/AppNavigation.kt, feature/details/presentation/src/.../DetailsViewModel.kt
DetailsScreen data class extended with owner & repo (defaults) and repositoryId defaulted. AppNavigation now supplies owner/repo to Details viewModel; DetailsViewModel accepts ownerParam/repoParam and prefers owner/repo-based lookup when provided.
Repository API & implementation
feature/details/domain/.../DetailsRepository.kt, feature/details/data/.../DetailsRepositoryImpl.kt
Added suspend fun getRepositoryByOwnerAndName(owner,name): GithubRepoSummary to repo interface and implementation; refactored getRepositoryById to use toGithubRepoSummary() mapping.
Dev profile enum & usages
feature/dev-profile/domain/.../RepoFilterType.kt, feature/dev-profile/presentation/.../*.kt
Removed ALL from RepoFilterType; removed ALL handling and mappings across DeveloperProfile UI/state/viewmodel, and updated default filter to WITH_RELEASES.
Localization additions
core/presentation/src/commonMain/composeResources/values-*/strings-*.xml (multiple locales)
Added release time strings and bottom navigation titles across many locales (bn, es, fr, it, ja, kr, pl, ru, tr, zh-rCN).

Sequence Diagram(s)

sequenceDiagram
    participant User as User / OS
    participant Platform as Platform Handler<br/>(Android / macOS / Desktop)
    participant App as App Composable
    participant Parser as DeepLinkParser
    participant Nav as Navigation
    participant VM as DetailsViewModel
    participant Repo as DetailsRepository

    User->>Platform: Open deep link (e.g. githubstore://repo/owner/name)
    Platform->>App: Deliver deepLinkUri (intent / open-uri / CLI arg)
    App->>Parser: parse(deepLinkUri)
    Parser-->>App: DeepLinkDestination.Repository(owner, repo)
    App->>Nav: navigate to DetailsScreen(owner, repo)
    Nav->>VM: instantiate with ownerParam, repoParam
    VM->>Repo: getRepositoryByOwnerAndName(owner, repo)
    Repo-->>VM: GithubRepoSummary
    VM-->>Nav: update state
    Nav->>App: render DetailsScreen with repo data
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~35 minutes

Possibly related PRs

Poem

🐰 I nibble links from far and near,

githubstore paths now crystal clear,
Owner, repo — I hop and find,
Details screen waits, all neatly lined,
A rabbit's cheer for routes sincere.

🚥 Pre-merge checks | ✅ 3 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 5.56% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main feature being implemented—deep linking for repository details—which aligns with the comprehensive changes across Android manifests, iOS configuration, desktop app, navigation, and deep link parsing infrastructure.
Merge Conflict Detection ✅ Passed ✅ No merge conflicts detected when merging into main

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch deeplink

No actionable comments were generated in the recent review. 🎉


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🤖 Fix all issues with AI agents
In `@composeApp/src/androidMain/AndroidManifest.xml`:
- Around line 62-73: Remove the overly-broad intent filter that claims
github.com (the <intent-filter> with action android.intent.action.VIEW and <data
android:host="github.com" android:pathPattern="/.*/..*" android:scheme="https"
/>) from AndroidManifest.xml; instead rely on your custom scheme
(githubstore://) and your verified domain (github-store.org), or implement an
explicit user-initiated mechanism like ACTION_SEND/share-target for GitHub links
so you don’t intercept third-party github.com URLs without autoVerify and
assetlinks control.

In
`@composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/deeplink/DeepLinkParser.kt`:
- Around line 31-53: The parser applies excluded-path validation only for GitHub
URLs via isValidOwnerRepo but not for the custom scheme or github-store.org
query; update DeepLinkParser so that both the githubstore:// branch (where you
call parseOwnerRepo on the removed prefix) and the storeQueryPattern branch
(where you call parseOwnerRepo on repoParam) enforce the same excluded-path
checks—either by having parseOwnerRepo perform isValidOwnerRepo(owner, repo) and
return null/throw for invalid paths, or by calling isValidOwnerRepo on the
owner/repo returned by parseOwnerRepo before returning
DeepLinkDestination.Repository; adjust parseOwnerRepo, isValidOwnerRepo and the
two call sites accordingly to ensure uniform validation.

In `@composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/Main.kt`:
- Around line 29-45: The effect using LaunchedEffect(deepLinkUri) won't re-run
for identical URIs; wrap the URI in an event that always changes (e.g., create
DeepLinkEvent(val uri: String, val timestamp: Long =
System.currentTimeMillis())) and switch the LaunchedEffect key to the
DeepLinkEvent, then call DeepLinkParser.parse(event.uri) and navigate with
navBackStack.navigate(GithubStoreGraph.DetailsScreen(owner = destination.owner,
repo = destination.repo)) as before; alternatively, if you prefer clearing
state, reset deepLinkUri to null after handling inside the same block so
subsequent identical intents update the state and re-trigger the effect.

In `@composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt`:
- Around line 18-31: deepLinkUri is created with mutableStateOf(...) inside the
composable so it resets on recomposition and the Desktop.setOpenURIHandler
registration runs repeatedly; fix by making the state stable with remember {
mutableStateOf(args.firstOrNull()) } (replace mutableStateOf(...) with remember
{ mutableStateOf(...) } for deepLinkUri) and move the
Desktop.getDesktop()/setOpenURIHandler(...) logic into a side-effect that runs
once (e.g., DisposableEffect(Unit) or LaunchedEffect(Unit)) where you register
the handler (using setOpenURIHandler { event -> deepLinkUri =
event.uri.toString() }) and restore the previous handler or clear it onDispose;
reference deepLinkUri, mutableStateOf, remember, setOpenURIHandler,
Desktop.getDesktop, and DisposableEffect/LaunchedEffect when making the change.

In
`@feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/DetailsRepositoryImpl.kt`:
- Around line 68-74: Sanitize and validate owner/name from all deep-link sources
before calling getRepositoryByOwnerAndName: URL-decode values returned by
DeepLinkParser.extractQueryParam, ensure DeepLinkParser.parseOwnerRepo performs
strict validation (reject empty, any '/' characters, path-traversal patterns
like '..', and characters '?', '#' and other special chars), and apply
DeepLinkParser.isValidOwnerRepo uniformly for github.com, githubstore://repo/,
and github-store.org paths; then only pass validated owner/name into
DetailsRepositoryImpl.getRepositoryByOwnerAndName so Ktor never receives
unvalidated path segments.
🧹 Nitpick comments (4)
composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/components/RateLimitDialog.kt (1)

131-135: Preview data has remaining > limit, which is unrealistic.

The preview passes remaining = 2000 with limit = 1000 and resetTimestamp = 0L. While this doesn't affect functionality, more realistic preview data (e.g., remaining = 0, resetTimestamp = System.currentTimeMillis() + 60_000) would make the preview more useful for visual verification.

composeApp/src/androidMain/AndroidManifest.xml (1)

75-86: Add android:autoVerify="true" to the github-store.org intent filter and host /.well-known/assetlinks.json.

Since github-store.org is your project's domain, implement Android App Links verification by adding android:autoVerify="true" to this intent filter and hosting an assetlinks.json file on the domain. This allows Android to verify your app's ownership and bypass user disambiguation dialogs.

The DeepLinkParser correctly handles the query parameter parsing for this URL format—no changes needed there.

composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/GithubStoreGraph.kt (1)

17-21: Consider documenting the DetailsScreen construction modes with KDoc.

While DetailsScreen can technically be constructed with all defaults, the DetailsViewModel correctly disambiguates between the two modes at lines 98–102:

val repo = if (ownerParam.isNotEmpty() && repoParam.isNotEmpty()) {
    detailsRepository.getRepositoryByOwnerAndName(ownerParam, repoParam)
} else {
    detailsRepository.getRepositoryById(repositoryId)
}

All call sites consistently follow the intended pattern (either repositoryId or owner+repo, never both). For improved maintainability, add a KDoc comment to DetailsScreen clarifying: "Use repositoryId for direct repository lookup (ownerParam/repoParam remain empty), or owner+repo for deep link navigation (repositoryId remains -1L)."

composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/deeplink/DeepLinkParser.kt (1)

58-64: Redundant isNotEmpty() checks after filtering.

segments is already filtered to non-empty strings on Line 59, so segments[0].isNotEmpty() && segments[1].isNotEmpty() on Line 60 are always true. You can simplify to just segments.size >= 2.

Comment on lines +62 to +73
<!-- GitHub repository links: https://github.com/{owner}/{repo} -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />

<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />

<data
android:host="github.com"
android:pathPattern="/.*/..*"
android:scheme="https" />
</intent-filter>
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Claiming github.com URLs without App Links verification is problematic.

This intent filter intercepts https://github.com URLs without android:autoVerify="true" and a corresponding /.well-known/assetlinks.json on github.com (which you don't control). This causes:

  1. Disambiguation dialogs: Android will prompt users to choose between your app and the browser/GitHub app for every matching URL, degrading UX.
  2. Overly broad pathPattern: "/.*/..*" matches not just /{owner}/{repo} but also /{owner}/{repo}/issues, /{owner}/{repo}/tree/main/src/..., profile URLs like /{user}/stars, etc. Android's pathPattern regex is limited and can't constrain to exactly two segments.
  3. User trust: Intercepting a major third-party domain may appear suspicious and could cause issues with Play Store review.

Consider removing this filter and instead relying solely on the custom githubstore:// scheme and your own domain (github-store.org). If you want to support GitHub URLs, implement a share-target or use ACTION_SEND with text filtering, which is opt-in by the user.

🤖 Prompt for AI Agents
In `@composeApp/src/androidMain/AndroidManifest.xml` around lines 62 - 73, Remove
the overly-broad intent filter that claims github.com (the <intent-filter> with
action android.intent.action.VIEW and <data android:host="github.com"
android:pathPattern="/.*/..*" android:scheme="https" />) from
AndroidManifest.xml; instead rely on your custom scheme (githubstore://) and
your verified domain (github-store.org), or implement an explicit user-initiated
mechanism like ACTION_SEND/share-target for GitHub links so you don’t intercept
third-party github.com URLs without autoVerify and assetlinks control.

Comment on lines +31 to +53
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/?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)
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Inconsistent excluded-path validation across URL schemes.

The githubExcludedPaths check is applied only for github.com URLs (via isValidOwnerRepo), but not for githubstore:// or github-store.org URLs. If a github-store.org link contains ?repo=settings/something, it would be accepted as a valid repository.

This may be intentional (app-owned schemes are trusted), but worth confirming. If these schemes could be triggered by external actors, consider applying validation uniformly.

🤖 Prompt for AI Agents
In
`@composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/deeplink/DeepLinkParser.kt`
around lines 31 - 53, The parser applies excluded-path validation only for
GitHub URLs via isValidOwnerRepo but not for the custom scheme or
github-store.org query; update DeepLinkParser so that both the githubstore://
branch (where you call parseOwnerRepo on the removed prefix) and the
storeQueryPattern branch (where you call parseOwnerRepo on repoParam) enforce
the same excluded-path checks—either by having parseOwnerRepo perform
isValidOwnerRepo(owner, repo) and return null/throw for invalid paths, or by
calling isValidOwnerRepo on the owner/repo returned by parseOwnerRepo before
returning DeepLinkDestination.Repository; adjust parseOwnerRepo,
isValidOwnerRepo and the two call sites accordingly to ensure uniform
validation.

Comment on lines +29 to +45
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 */
}
}
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Repeated identical deep link URIs won't re-trigger navigation.

LaunchedEffect(deepLinkUri) only re-runs when the key value changes. If the user opens the same githubstore://repo/owner/repo link twice in a row, deepLinkUri stays the same string and the effect won't fire again. On Android with singleTask, successive intents carrying the same URI will update deepLinkUri via onNewIntent, but the state value won't actually change if it's the same string.

Consider using a wrapper that includes a timestamp or incrementing counter to guarantee re-trigger:

data class DeepLinkEvent(val uri: String, val timestamp: Long = System.currentTimeMillis())

Alternatively, reset deepLinkUri to null after processing, though that requires a callback from App to the platform layer.

🤖 Prompt for AI Agents
In `@composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/Main.kt` around
lines 29 - 45, The effect using LaunchedEffect(deepLinkUri) won't re-run for
identical URIs; wrap the URI in an event that always changes (e.g., create
DeepLinkEvent(val uri: String, val timestamp: Long =
System.currentTimeMillis())) and switch the LaunchedEffect key to the
DeepLinkEvent, then call DeepLinkParser.parse(event.uri) and navigate with
navBackStack.navigate(GithubStoreGraph.DetailsScreen(owner = destination.owner,
repo = destination.repo)) as before; alternatively, if you prefer clearing
state, reset deepLinkUri to null after handling inside the same block so
subsequent identical intents update the state and re-trigger the effect.

Comment on lines +18 to +31
// 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()
}
}
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Missing remember — deep link state is reset on every recomposition.

mutableStateOf(args.firstOrNull()) on line 19 is inside the application {} composable scope but is not wrapped in remember. Every recomposition of the application block will re-create this state, discarding any URI set by setOpenURIHandler. The same applies to the handler registration — it runs on every recomposition.

Proposed fix
+    import androidx.compose.runtime.remember
+    import androidx.compose.runtime.LaunchedEffect
...

-    var deepLinkUri by mutableStateOf(args.firstOrNull())
+    var deepLinkUri by remember { mutableStateOf(args.firstOrNull()) }

-    if (Desktop.isDesktopSupported()) {
-        Desktop.getDesktop().let { desktop ->
-            if (desktop.isSupported(Desktop.Action.APP_OPEN_URI)) {
-                desktop.setOpenURIHandler { event ->
-                    deepLinkUri = event.uri.toString()
-                }
-            }
-        }
-    }
+    LaunchedEffect(Unit) {
+        if (Desktop.isDesktopSupported()) {
+            Desktop.getDesktop().let { desktop ->
+                if (desktop.isSupported(Desktop.Action.APP_OPEN_URI)) {
+                    desktop.setOpenURIHandler { event ->
+                        deepLinkUri = event.uri.toString()
+                    }
+                }
+            }
+        }
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 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()
}
}
}
}
// Deep link state — can come from CLI args or macOS open-url event
var deepLinkUri by remember { mutableStateOf(args.firstOrNull()) }
// Register macOS URI scheme handler (githubstore://)
// When the packaged .app is opened via a URL, macOS delivers it here
LaunchedEffect(Unit) {
if (Desktop.isDesktopSupported()) {
Desktop.getDesktop().let { desktop ->
if (desktop.isSupported(Desktop.Action.APP_OPEN_URI)) {
desktop.setOpenURIHandler { event ->
deepLinkUri = event.uri.toString()
}
}
}
}
}
🤖 Prompt for AI Agents
In `@composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt` around
lines 18 - 31, deepLinkUri is created with mutableStateOf(...) inside the
composable so it resets on recomposition and the Desktop.setOpenURIHandler
registration runs repeatedly; fix by making the state stable with remember {
mutableStateOf(args.firstOrNull()) } (replace mutableStateOf(...) with remember
{ mutableStateOf(...) } for deepLinkUri) and move the
Desktop.getDesktop()/setOpenURIHandler(...) logic into a side-effect that runs
once (e.g., DisposableEffect(Unit) or LaunchedEffect(Unit)) where you register
the handler (using setOpenURIHandler { event -> deepLinkUri =
event.uri.toString() }) and restore the previous handler or clear it onDispose;
reference deepLinkUri, mutableStateOf, remember, setOpenURIHandler,
Desktop.getDesktop, and DisposableEffect/LaunchedEffect when making the change.

Comment on lines +68 to 74
override suspend fun getRepositoryByOwnerAndName(owner: String, name: String): GithubRepoSummary {
return httpClient.executeRequest<RepoByIdNetwork> {
get("/repos/$owner/$name") {
header(HttpHeaders.Accept, "application/vnd.github+json")
}
}.getOrThrow().toGithubRepoSummary()
}
Copy link
Contributor

@coderabbitai coderabbitai bot Feb 14, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

rg -n "DeepLinkParser\|deeplink\|deep_link" --type=kt -C5

Repository: rainxchzed/Github-Store

Length of output: 93


🏁 Script executed:

rg -n "DeepLinkParser|deeplink|deep_link" -C5

Repository: rainxchzed/Github-Store

Length of output: 4363


🏁 Script executed:

cat -n composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/deeplink/DeepLinkParser.kt

Repository: rainxchzed/Github-Store

Length of output: 3649


🏁 Script executed:

cat -n feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/DetailsRepositoryImpl.kt | head -100

Repository: rainxchzed/Github-Store

Length of output: 4676


🏁 Script executed:

fd -n "executeRequest" --type f

Repository: rainxchzed/Github-Store

Length of output: 297


🏁 Script executed:

rg -n "fun executeRequest" -A 10

Repository: rainxchzed/Github-Store

Length of output: 49


🏁 Script executed:

rg -n "executeRequest" -B 2 -A 8 | head -50

Repository: rainxchzed/Github-Store

Length of output: 6868


🏁 Script executed:

fd "executeRequest" --extension kt | xargs cat | head -100

Repository: rainxchzed/Github-Store

Length of output: 49


🏁 Script executed:

rg -n "inline.*executeRequest\|fun executeRequest" -B 2 -A 15

Repository: rainxchzed/Github-Store

Length of output: 49


🏁 Script executed:

find . -name "*.kt" -type f | xargs grep -l "fun executeRequest" | head -5

Repository: rainxchzed/Github-Store

Length of output: 49


🏁 Script executed:

rg -n "executeRequest" core/ -B 2 -A 12 | head -80

Repository: rainxchzed/Github-Store

Length of output: 8059


🏁 Script executed:

cat -n core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/HttpClientFactory.kt | head -110

Repository: rainxchzed/Github-Store

Length of output: 4029


Sanitize owner and name parameters from all deep-link paths before use in HTTP requests.

The DeepLinkParser validates these parameters inconsistently:

  • isValidOwnerRepo() is only called for github.com paths (line 41), not for githubstore://repo/ paths (line 33) or github-store.org query params (line 51)
  • parseOwnerRepo() checks only for non-empty strings, missing validation against special characters, path traversal patterns (../), and excluded paths
  • extractQueryParam() does not URL-decode extracted values, allowing encoded payloads like %2e%2e to bypass filters
  • Ktor does not automatically URL-encode string-interpolated path segments (Ktor 3.2.3), so malicious input reaches the API as-is

Apply consistent validation across all deep-link formats: reject empty strings, special characters (/, .., ?, #), excluded paths, and URL-decode query parameters before validation.

🤖 Prompt for AI Agents
In
`@feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/DetailsRepositoryImpl.kt`
around lines 68 - 74, Sanitize and validate owner/name from all deep-link
sources before calling getRepositoryByOwnerAndName: URL-decode values returned
by DeepLinkParser.extractQueryParam, ensure DeepLinkParser.parseOwnerRepo
performs strict validation (reject empty, any '/' characters, path-traversal
patterns like '..', and characters '?', '#' and other special chars), and apply
DeepLinkParser.isValidOwnerRepo uniformly for github.com, githubstore://repo/,
and github-store.org paths; then only pass validated owner/name into
DetailsRepositoryImpl.getRepositoryByOwnerAndName so Ktor never receives
unvalidated path segments.

Copy link
Owner Author

@rainxchzed rainxchzed Feb 14, 2026

Choose a reason for hiding this comment

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

@coderabbitai Could you provide implementation?

Copy link
Contributor

Choose a reason for hiding this comment

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

Task: Implement deep-link parameter sanitization and validation

  • Enhance DeepLinkParser validation to reject malicious input
  • Add URL-decoding for query parameters
  • Apply consistent validation across all deep-link formats
  • Ensure special characters and path traversal patterns are blocked

💡 For code changes across multiple files, use @coderabbitai plan in an issue comment

Copy link
Owner Author

Choose a reason for hiding this comment

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

@coderabbitai i meant code implementation, maybe proper fix for this issue

Copy link
Contributor

Choose a reason for hiding this comment

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

@rainxchzed, I'll implement the proper security fix for the deep-link parameter sanitization issue.

Task: Implement deep-link parameter sanitization and validation

  • Add URL decoding functionality to handle percent-encoded characters
  • Implement strict validation for owner and repo parameters
  • Apply consistent validation across all deep-link formats (githubstore://, github.com, github-store.org)
  • Block special characters, path traversal patterns, and malicious input

💡 For code changes across multiple files, use @coderabbitai plan in an issue comment

Copy link
Owner Author

Choose a reason for hiding this comment

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

@coderabbitai could you write down the solution right here?

Copy link
Contributor

Choose a reason for hiding this comment

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

@rainxchzed, here's the complete updated code for DeepLinkParser.kt with all security fixes:

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 {
    // Special characters that should not appear in valid GitHub usernames or repo names
    private val INVALID_CHARS = setOf('/', '\\', '?', '#', '@', ':', '*', '"', '<', '>', '|', '%', '&', '=')
    
    // Path traversal patterns to reject
    private val FORBIDDEN_PATTERNS = listOf("..", "~", "\u0000")
    
    // GitHub reserved paths that cannot be usernames
    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]
            // Apply strict validation to all parsed owner/repo pairs
            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 {
        // Check for empty strings
        if (owner.isEmpty() || repo.isEmpty()) {
            return false
        }
        
        // Check for invalid characters
        if (owner.any { it in INVALID_CHARS } || repo.any { it in INVALID_CHARS }) {
            return false
        }
        
        // Check for forbidden patterns (path traversal, etc.)
        if (FORBIDDEN_PATTERNS.any { pattern -> 
            owner.contains(pattern, ignoreCase = true) || 
            repo.contains(pattern, ignoreCase = true) 
        }) {
            return false
        }
        
        // Check for control characters
        if (owner.any { it.isISOControl() } || repo.any { it.isISOControl() }) {
            return false
        }
        
        // Check for whitespace (GitHub usernames/repos don't contain spaces)
        if (owner.contains(' ') || repo.contains(' ')) {
            return false
        }
        
        // Check against excluded paths
        if (EXCLUDED_PATHS.contains(owner.lowercase())) {
            return false
        }
        
        // Additional length validation (GitHub has limits)
        // GitHub usernames: 1-39 characters, repo names: 1-100 characters
        if (owner.length > 39 || repo.length > 100) {
            return false
        }
        
        // GitHub usernames and repos must start with alphanumeric
        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('=')
            if (keyValue.size == 2 && keyValue[0] == key) {
                // Return the encoded value; it will be decoded by the caller
                return keyValue[1]
            }
        }
        return null
    }
}

Key changes made:

  1. Added security constants (lines 10-23): Define invalid characters, forbidden patterns, and excluded GitHub paths
  2. Added urlDecode() function (lines 68-99): Handles percent-encoded characters like %2e%2e..
  3. Updated parse() to decode all inputs (lines 27, 37, 48): URLs are decoded before validation
  4. Enhanced validation with isStrictlyValidOwnerRepo() (lines 118-167):
    • Blocks special characters (/, .., ?, #, etc.)
    • Prevents path traversal attacks
    • Validates against GitHub's actual constraints (length limits, character requirements)
    • Rejects reserved GitHub paths
  5. Updated parseOwnerRepo() (lines 101-116): Now uses strict validation for all parsed parameters

This implementation ensures that malicious inputs like ../admin, %2e%2e%2fadmin, or user/../other are rejected before reaching the HTTP client.

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`).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant