Skip to content

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

Merged
rainxchzed merged 9 commits intomainfrom
deeplink
Feb 16, 2026
Merged

feat(app): Implement deep linking for repository details#246
rainxchzed merged 9 commits intomainfrom
deeplink

Conversation

@rainxchzed
Copy link
Owner

@rainxchzed rainxchzed commented Feb 14, 2026

Summary by CodeRabbit

  • New Features

    • Deep-linking across platforms (macOS URL scheme, Android intent filters, desktop handlers) to open repositories and app pages.
    • Single-instance desktop handling with URI forwarding and instance-to-instance URI delivery.
    • App navigates to repository details from links; repository lookup by owner/name added.
  • Improvements

    • Rate limit dialog displays accurate values and renders more predictably.
    • Developer profile defaults to show releases first; global "All" filter removed.
    • Added release-time and bottom-navigation translations for many languages.

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

Caution

Review failed

The pull request is closed.

Walkthrough

Adds cross-platform deep-link support (Android intent-filters and singleTask launchMode, desktop single-instance forwarding and URI registration, macOS URL scheme), a DeepLinkParser, App wiring to accept deepLinkUri, owner/repo-based navigation/VM/repository lookups, RateLimitDialog API change, removal of RepoFilterType.ALL, and many localization additions.

Changes

Cohort / File(s) Summary
Android deep links
composeApp/src/androidMain/AndroidManifest.xml, composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/MainActivity.kt
Added android:launchMode="singleTask" and multiple intent-filters for githubstore://, https://github.com/..., and https://github-store.org/.... MainActivity captures and streams deep-link URIs into the composable App via state and onNewIntent handling.
macOS / Gradle plist
composeApp/build.gradle.kts
Added infoPlist / extraKeysRawXml under macOS target to register CFBundleURLTypes for the githubstore scheme.
Desktop deep-link & single-instance
composeApp/src/jvmMain/kotlin/.../DesktopApp.kt, composeApp/src/jvmMain/kotlin/.../DesktopDeepLink.kt, gradle.properties
Desktop main now accepts args, registers URI scheme (Windows/Linux), attempts forwarding to running instance over TCP, starts a listener for forwarded URIs/OpenURI, and passes deepLinkUri into App; added compose.desktop.packaging.checkJdkVendor=false.
Deep link parser
composeApp/src/commonMain/kotlin/.../app/deeplink/DeepLinkParser.kt
New DeepLinkDestination sealed type and DeepLinkParser.parse(uri) supporting githubstore://repo/..., https://github.com/..., and https://github-store.org/app/?repo=... with decoding and strict owner/repo validation and helper functions.
App wiring (multi-platform)
composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/Main.kt, composeApp/src/androidMain/.../MainActivity.kt, composeApp/src/jvmMain/kotlin/.../DesktopApp.kt
App gains deepLinkUri parameter; platforms initialize and forward incoming URIs into App; LaunchedEffect parses and navigates to DetailsScreen when appropriate.
Details navigation & VM injection
composeApp/src/commonMain/kotlin/.../app/navigation/GithubStoreGraph.kt, composeApp/src/commonMain/kotlin/.../app/navigation/AppNavigation.kt, feature/details/presentation/.../DetailsViewModel.kt
DetailsScreen data class gains owner and repo (defaults) and repositoryId now has a default; navigation passes owner/repo to DetailsViewModel, which accepts ownerParam/repoParam and prefers owner/repo lookups when provided.
Repository API & impl
feature/details/domain/.../DetailsRepository.kt, feature/details/data/.../DetailsRepositoryImpl.kt
Added suspend fun getRepositoryByOwnerAndName(owner, name): GithubRepoSummary; refactored mapping RepoByIdNetwork.toGithubRepoSummary() and reused it in lookups.
Rate limit dialog
composeApp/src/commonMain/kotlin/.../app/components/RateLimitDialog.kt
Changed rateLimitInfo parameter from nullable to non-null and updated preview to supply a concrete RateLimitInfo; UI now assumes non-null rateLimitInfo.
Dev profile enum & usages
feature/dev-profile/domain/.../RepoFilterType.kt, feature/dev-profile/presentation/.../*.kt
Removed ALL from RepoFilterType; removed ALL handling and changed default filter to WITH_RELEASES across state, viewmodel, and UI components.
Localization additions
core/presentation/src/commonMain/composeResources/values-*/strings-*.xml (bn, es, fr, it, ja, kr, pl, ru, tr, zh-rCN, hi)
Added relative release-time strings and bottom navigation titles across many locales (new keys added, purely additive).

Sequence Diagram(s)

sequenceDiagram
    participant User as User / OS
    participant Platform as PlatformHandler
    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 (intent / open-uri / CLI arg)
    Platform->>App: deliver deepLinkUri
    App->>Parser: parse(deepLinkUri)
    Parser-->>App: Repository(owner,repo) / None
    alt 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: provide data
        Nav->>App: render DetailsScreen
    else None
        App->>App: ignore / no-op
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 I nibble links and hop with glee,

A scheme named githubstore guides me,
Owner and repo, a tasty clue,
I tunnel straight to details new,
Hooray — I found the codey stew!

🚥 Pre-merge checks | ✅ 2 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 17.86% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Merge Conflict Detection ⚠️ Warning ❌ Merge conflicts detected (31 files):

⚔️ composeApp/build.gradle.kts (content)
⚔️ composeApp/src/androidMain/AndroidManifest.xml (content)
⚔️ composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/MainActivity.kt (content)
⚔️ composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/Main.kt (content)
⚔️ composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/components/RateLimitDialog.kt (content)
⚔️ composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt (content)
⚔️ composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/GithubStoreGraph.kt (content)
⚔️ composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt (content)
⚔️ core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidInstaller.kt (content)
⚔️ core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopInstaller.kt (content)
⚔️ core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/SystemArchitecture.kt (content)
⚔️ core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml (content)
⚔️ core/presentation/src/commonMain/composeResources/values-es/strings-es.xml (content)
⚔️ core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml (content)
⚔️ core/presentation/src/commonMain/composeResources/values-it/strings-it.xml (content)
⚔️ core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml (content)
⚔️ core/presentation/src/commonMain/composeResources/values-kr/strings-kr.xml (content)
⚔️ core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml (content)
⚔️ core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml (content)
⚔️ core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml (content)
⚔️ core/presentation/src/commonMain/composeResources/values-zh-rCN/strings-zh-rCN.xml (content)
⚔️ feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/DetailsRepositoryImpl.kt (content)
⚔️ feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/repository/DetailsRepository.kt (content)
⚔️ feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt (content)
⚔️ feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/utils/SystemArchitecture.kt (content)
⚔️ feature/dev-profile/domain/src/commonMain/kotlin/zed/rainxch/devprofile/domain/model/RepoFilterType.kt (content)
⚔️ feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/DeveloperProfileRoot.kt (content)
⚔️ feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/DeveloperProfileState.kt (content)
⚔️ feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/DeveloperProfileViewModel.kt (content)
⚔️ feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/components/FilterSortControls.kt (content)
⚔️ gradle.properties (content)

These conflicts must be resolved before merging into main.
Resolve conflicts locally and push changes to this branch.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately summarizes the main objective: implementing deep linking for repository details. It aligns with the changeset which adds deep link parsing, navigation, and platform-specific URI scheme registration.

✏️ 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

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 +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.

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`).
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.
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: 4

🤖 Fix all issues with AI agents
In
`@composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/deeplink/DeepLinkParser.kt`:
- Around line 162-176: The extractQueryParam function can mis-handle values that
contain '=' because it uses param.split('=') without a limit; update the
splitting to only split on the first '=' (use param.split('=', limit = 2)) so
keyValue will have at most two parts and keyValue[1] will contain the full
value; keep the existing check for keyValue.size == 2 and return keyValue[1] as
before (optionally URL-decode it elsewhere if needed).

In `@composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt`:
- Around line 24-31: The composable body currently performs side-effects that
will run on every recomposition: calling initKoin() (which calls startKoin and
can throw KoinAppAlreadyStartedException) and
DesktopDeepLink.startInstanceListener (which spawns a listener thread). Move
these calls out of the composable/application block (e.g., invoke initKoin() and
start the DesktopDeepLink listener before entering application { ... }) or, if
they must remain in composable scope, wrap them in a single-run effect such as
LaunchedEffect(Unit) so initKoin() and DesktopDeepLink.startInstanceListener are
executed only once and do not re-run on recomposition.

In `@composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopDeepLink.kt`:
- Around line 67-92: The registerLinux() function currently returns early if the
.desktop file exists, which leaves a stale Exec= path if the binary moved;
change the logic to read desktopFile (created from DESKTOP_FILE_NAME in
appsDir), parse or extract the existing Exec= line and compare it with
resolveExePath(), and if they differ rewrite the .desktop file contents (using
the same template referencing SCHEME) and re-run runCommand("xdg-mime",
"default", "$DESKTOP_FILE_NAME.desktop", "x-scheme-handler/$SCHEME"); keep the
early-return only when Exec matches the resolved path and ensure
appsDir.mkdirs() remains before writing.
- Around line 114-136: The ServerSocket in startInstanceListener binds to all
interfaces (0.0.0.0); change it to bind explicitly to the loopback address so
only local processes can connect (matching tryForwardToRunningInstance's
127.0.0.1). Update the ServerSocket creation in startInstanceListener to use the
loopback/InetAddress.getLoopbackAddress() (or
InetAddress.getByName("127.0.0.1")) overload (e.g.,
ServerSocket(SINGLE_INSTANCE_PORT, 50, loopbackAddress)) and keep the rest of
the accept/read/close logic intact.
🧹 Nitpick comments (2)
composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopDeepLink.kt (1)

154-165: runCommand has no timeout — a hung process will block the calling thread indefinitely.

process.waitFor() blocks without a timeout. If a registry command or xdg-mime hangs, the application startup stalls. Consider using waitFor(timeout, unit) and destroying the process on timeout.

Proposed fix
+import java.util.concurrent.TimeUnit
...
     private fun runCommand(vararg cmd: String): String? {
         return try {
             val process = ProcessBuilder(*cmd)
                 .redirectErrorStream(true)
                 .start()
             val output = process.inputStream.bufferedReader().readText()
-            process.waitFor()
+            if (!process.waitFor(10, TimeUnit.SECONDS)) {
+                process.destroyForcibly()
+            }
             output
         } catch (_: Exception) {
             null
         }
     }
composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/deeplink/DeepLinkParser.kt (1)

62-95: Custom urlDecode doesn't handle multi-byte UTF-8 sequences.

Each %XX is decoded independently as a Char, so multi-byte UTF-8 characters (e.g., %C3%A9 → 'é') would produce mojibake. GitHub owner and repo names are ASCII-only, so this is safe for the current use case, but consider using a KMP-compatible standard decoder if the scope ever broadens.

Comment on lines +67 to +92
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")
}
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

Linux: .desktop file is never updated if the executable path changes.

Line 72 returns early if the file already exists. If the user reinstalls the app to a different location, the stale Exec= path will point to the old binary, and deep links will silently fail. Consider comparing the current Exec= path against resolveExePath() and rewriting if it differs.

🤖 Prompt for AI Agents
In `@composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopDeepLink.kt`
around lines 67 - 92, The registerLinux() function currently returns early if
the .desktop file exists, which leaves a stale Exec= path if the binary moved;
change the logic to read desktopFile (created from DESKTOP_FILE_NAME in
appsDir), parse or extract the existing Exec= line and compare it with
resolveExePath(), and if they differ rewrite the .desktop file contents (using
the same template referencing SCHEME) and re-run runCommand("xdg-mime",
"default", "$DESKTOP_FILE_NAME.desktop", "x-scheme-handler/$SCHEME"); keep the
early-return only when Exec matches the resolved path and ensure
appsDir.mkdirs() remains before writing.

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.
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: 1

🤖 Fix all issues with AI agents
In
`@composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/deeplink/DeepLinkParser.kt`:
- Around line 62-95: The urlDecode function incorrectly decodes
percent-encodings byte-by-byte (breaking multi-byte UTF-8 sequences), always
converts '+' to space (wrong for URI path segments), and silently swallows
exceptions; change urlDecode to accumulate percent-encoded bytes (collect
consecutive %XX bytes and regular ASCII bytes into a ByteArray), then decode the
byte array with UTF-8 to produce the String (use StandardCharsets.UTF_8), remove
the unconditional '+'→' ' conversion or add a boolean parameter (e.g.,
urlDecode(value: String, formUrlEncoded: Boolean = false)) so '+' is only
converted for form-urlencoded query values, and replace the empty catch with
logging the exception via the module logger before rethrowing or returning the
original value as appropriate; update callers to pass the correct formUrlEncoded
flag for query params vs path segments.
🧹 Nitpick comments (2)
composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopDeepLink.kt (2)

155-166: runCommand waits indefinitely for process completion.

process.waitFor() has no timeout. A hung reg or xdg-mime command would block the calling thread forever. Consider using waitFor(timeout, unit) with a reasonable deadline (e.g. 10 seconds) and destroying the process on timeout.

Proposed fix
+import java.util.concurrent.TimeUnit
 ...
     private fun runCommand(vararg cmd: String): String? {
         return try {
             val process = ProcessBuilder(*cmd)
                 .redirectErrorStream(true)
                 .start()
             val output = process.inputStream.bufferedReader().readText()
-            process.waitFor()
+            if (!process.waitFor(10, TimeUnit.SECONDS)) {
+                process.destroyForcibly()
+            }
             output
         } catch (_: Exception) {
             null
         }
     }

115-137: Silent failure when listener cannot bind to the port.

If the port is occupied by another application, ServerSocket(...) throws, the outer catch swallows it, and the daemon thread dies silently. The app will appear to run normally but all inter-instance deep-link forwarding will be broken with no diagnostic.

At minimum, log the exception so the failure is discoverable.

Comment on lines +62 to +95
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
}
}
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

urlDecode has a few correctness gaps worth noting.

  1. Multi-byte UTF-8: Each %XX byte is independently converted via code.toChar(), so multi-byte sequences (e.g. %C3%A9 → é) will produce incorrect characters. Low risk here since GitHub owner/repo names are ASCII, but this would break if the function is reused elsewhere.

  2. + → space in path context: Decoding + as space is application/x-www-form-urlencoded behavior, not standard percent-encoding for URI paths. This function is applied to both path segments and query params. For paths, a literal + would be incorrectly decoded. Impact is negligible since spaces are rejected by validation and + isn't valid in GitHub names.

  3. Swallowed exception (detekt flag): The catch block silently returns the original string. Consider logging the exception for debuggability.

Minimal fix for the swallowed exception
         } catch (e: Exception) {
+            // Log or handle decoding failure for debuggability
+            println("DeepLinkParser: URL decode failed for '$value': ${e.message}")
             value
         }
🧰 Tools
🪛 detekt (1.23.8)

[warning] 92-92: The caught exception is swallowed. The original exception could be lost.

(detekt.exceptions.SwallowedException)

🤖 Prompt for AI Agents
In
`@composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/deeplink/DeepLinkParser.kt`
around lines 62 - 95, The urlDecode function incorrectly decodes
percent-encodings byte-by-byte (breaking multi-byte UTF-8 sequences), always
converts '+' to space (wrong for URI path segments), and silently swallows
exceptions; change urlDecode to accumulate percent-encoded bytes (collect
consecutive %XX bytes and regular ASCII bytes into a ByteArray), then decode the
byte array with UTF-8 to produce the String (use StandardCharsets.UTF_8), remove
the unconditional '+'→' ' conversion or add a boolean parameter (e.g.,
urlDecode(value: String, formUrlEncoded: Boolean = false)) so '+' is only
converted for form-urlencoded query values, and replace the empty catch with
logging the exception via the module logger before rethrowing or returning the
original value as appropriate; update callers to pass the correct formUrlEncoded
flag for query params vs path segments.

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.
@rainxchzed rainxchzed merged commit c34a4d0 into main Feb 16, 2026
1 check passed
@rainxchzed rainxchzed deleted the deeplink branch February 16, 2026 15:56
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