-
Notifications
You must be signed in to change notification settings - Fork 30
feat(kilo-app): deep linking for existing web URLs #1935
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
iscekic
wants to merge
6
commits into
main
Choose a base branch
from
feature/deep-linking
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
1c56bcd
feat(kilo-app): add deep linking support for existing web URLs
iscekic 9467096
feat: add AASA and assetlinks.json for universal/app links
iscekic 13417c3
fix(kilo-app): address deep linking review feedback
iscekic 1eb3597
fix(kilo-app): prevent warm-start deep link replay on remount
iscekic 907e197
fix(kilo-app): validate org membership before deep link context switch
iscekic 2bae7c4
fix(kilo-app): persist deep link target through auth flow
iscekic File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| import { resolveDeepLink } from '@/lib/deep-link'; | ||
|
|
||
| /** | ||
| * Intercept incoming native URLs (universal links, custom scheme) | ||
| * and rewrite them to internal app routes before Expo Router processes them. | ||
| * | ||
| * @see https://docs.expo.dev/router/advanced/native-intent/ | ||
| */ | ||
| export function redirectSystemPath({ | ||
| path, | ||
| initial: _initial, | ||
| }: { | ||
| path: string; | ||
| initial: boolean; | ||
| }): string { | ||
| try { | ||
| return resolveDeepLink(path); | ||
| } catch { | ||
| return path; | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,117 @@ | ||
| import { type Href } from 'expo-router'; | ||
|
|
||
| export type PendingDeepLink = { | ||
| targetRoute: Href; | ||
| organizationId?: string; | ||
| }; | ||
|
|
||
| let pending: PendingDeepLink | null = null; | ||
| let listener: ((link: PendingDeepLink) => void) | null = null; | ||
|
|
||
| function setPendingDeepLink(link: PendingDeepLink) { | ||
| if (listener) { | ||
| // Warm start: deliver directly, don't store (avoids replay on remount) | ||
| listener(link); | ||
| } else { | ||
| // Cold start: stash for consumePendingDeepLink() on first mount | ||
| pending = link; | ||
| } | ||
| } | ||
|
|
||
| export function consumePendingDeepLink(): PendingDeepLink | null { | ||
| const link = pending; | ||
| pending = null; | ||
| return link; | ||
| } | ||
|
|
||
| /** Subscribe to deep links arriving while the app is open. */ | ||
| export function onPendingDeepLink(handler: (link: PendingDeepLink) => void): () => void { | ||
| listener = handler; | ||
| return () => { | ||
| if (listener === handler) { | ||
| listener = null; | ||
| } | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * Normalise an incoming URL (full or path-only) to a bare pathname. | ||
| * Returns `null` when the URL doesn't belong to our domain. | ||
| */ | ||
| function toPathname(raw: string): string | null { | ||
| // Already a relative path | ||
| if (raw.startsWith('/')) { | ||
| return raw; | ||
| } | ||
|
|
||
| try { | ||
| const url = new URL(raw); | ||
| const host = url.hostname.replace(/^www\./, ''); | ||
| if (host !== 'app.kilo.ai') { | ||
| return null; | ||
| } | ||
| return url.pathname; | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| const ORG_CLAW_RE = /^\/organizations\/([^/]+)\/claw\/?$/; | ||
| const ORG_RE = /^\/organizations\/([^/]+)\/?$/; | ||
|
|
||
| const INSTANCE_LIST: Href = '/(app)/(tabs)/(1_kiloclaw)/' as Href; | ||
| const PROFILE: Href = '/(app)/profile' as Href; | ||
|
|
||
| /** | ||
| * Map an incoming web URL / path to an internal app route. | ||
| * | ||
| * Every recognised URL is stored as a pending deep link so the app can | ||
| * navigate there after auth completes (if the user isn't logged in). | ||
| * The rewritten path is also returned for direct navigation when the | ||
| * user is already authenticated. | ||
| * | ||
| * - Non-org routes: stored + direct rewrite returned. | ||
| * - Org-scoped routes: stored (with orgId for context switch), | ||
| * returns `/(app)` so the app layout picks it up. | ||
| * - Unrecognised paths: returned as-is (Expo Router default handling). | ||
| */ | ||
| export function resolveDeepLink(raw: string): string { | ||
| const pathname = toPathname(raw); | ||
| if (pathname == null) { | ||
| return raw; | ||
| } | ||
|
|
||
| // /claw → instance list (personal context) | ||
| if (pathname === '/claw' || pathname === '/claw/') { | ||
| setPendingDeepLink({ targetRoute: INSTANCE_LIST }); | ||
| return INSTANCE_LIST as string; | ||
| } | ||
|
|
||
| // /profile → profile screen | ||
| if (pathname === '/profile' || pathname === '/profile/') { | ||
| setPendingDeepLink({ targetRoute: PROFILE }); | ||
| return PROFILE as string; | ||
| } | ||
|
|
||
| // /organizations/[orgId]/claw → switch to org context + instance list | ||
| const orgClawMatch = ORG_CLAW_RE.exec(pathname); | ||
| if (orgClawMatch?.[1]) { | ||
| setPendingDeepLink({ | ||
| targetRoute: INSTANCE_LIST, | ||
| organizationId: orgClawMatch[1], | ||
| }); | ||
| return '/(app)'; | ||
| } | ||
|
|
||
| // /organizations/[orgId] → switch to org context + profile | ||
| const orgMatch = ORG_RE.exec(pathname); | ||
| if (orgMatch?.[1]) { | ||
| setPendingDeepLink({ | ||
| targetRoute: PROFILE, | ||
| organizationId: orgMatch[1], | ||
| }); | ||
| return '/(app)'; | ||
| } | ||
|
|
||
| return raw; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| import { useRouter } from 'expo-router'; | ||
| import { useCallback, useEffect } from 'react'; | ||
| import { toast } from 'sonner-native'; | ||
|
|
||
| import { useAppContext } from '@/lib/context/context-context'; | ||
| import { consumePendingDeepLink, onPendingDeepLink, type PendingDeepLink } from '@/lib/deep-link'; | ||
| import { trpcClient } from '@/lib/trpc'; | ||
|
|
||
| /** | ||
| * Consumes pending deep links (set by +native-intent), validates access | ||
| * for org-scoped links, switches context if needed, and navigates. | ||
| * | ||
| * Handles both cold-start (pending link set before mount — covers the | ||
| * case where the user wasn't logged in and went through auth first) and | ||
| * warm-start (new link arriving while the app is already open). | ||
| * | ||
| * Must be called inside the (app) layout (inside providers, after auth). | ||
| */ | ||
| export function useDeepLink() { | ||
| const { setContext } = useAppContext(); | ||
| const router = useRouter(); | ||
|
|
||
| const handleLink = useCallback( | ||
| (link: PendingDeepLink) => { | ||
| const navigate = async () => { | ||
| try { | ||
| if (link.organizationId) { | ||
| const orgs = await trpcClient.organizations.list.query(); | ||
| const hasAccess = orgs.some(org => org.organizationId === link.organizationId); | ||
| if (!hasAccess) { | ||
| toast.error('You don\u2019t have access to this organization'); | ||
| return; | ||
| } | ||
| await setContext({ type: 'organization', organizationId: link.organizationId }); | ||
| } | ||
| router.replace(link.targetRoute); | ||
| } catch { | ||
| toast.error('Failed to open link'); | ||
| } | ||
| }; | ||
| void navigate(); | ||
| }, | ||
| [setContext, router] | ||
| ); | ||
|
|
||
| useEffect(() => { | ||
iscekic marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| // Cold start: consume any link set before this component mounted | ||
| // (includes links that waited through the auth flow) | ||
| const link = consumePendingDeepLink(); | ||
| if (link) { | ||
| handleLink(link); | ||
| } | ||
|
|
||
| // Warm start: listen for links arriving while the app is already open | ||
| return onPendingDeepLink(handleLink); | ||
| }, [handleLink]); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| { | ||
| "applinks": { | ||
| "details": [ | ||
| { | ||
| "appIDs": ["X96D76J65Z.com.kilocode.kiloapp"], | ||
| "components": [ | ||
| { | ||
| "/": "/claw", | ||
| "comment": "KiloClaw instance list" | ||
| }, | ||
| { | ||
| "/": "/claw/", | ||
| "comment": "KiloClaw instance list (trailing slash)" | ||
| }, | ||
| { | ||
| "/": "/profile", | ||
| "comment": "User profile" | ||
| }, | ||
| { | ||
| "/": "/profile/", | ||
| "comment": "User profile (trailing slash)" | ||
| }, | ||
| { | ||
| "/": "/organizations/*/claw", | ||
| "comment": "Org-scoped KiloClaw instance list" | ||
| }, | ||
| { | ||
| "/": "/organizations/*/claw/", | ||
| "comment": "Org-scoped KiloClaw instance list (trailing slash)" | ||
| }, | ||
| { | ||
| "/": "/organizations/*/*", | ||
| "exclude": true, | ||
| "comment": "Exclude all other org sub-pages from universal links" | ||
| }, | ||
| { | ||
| "/": "/organizations/*", | ||
iscekic marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| "comment": "Organization profile" | ||
| }, | ||
| { | ||
| "/": "/organizations/*/", | ||
| "comment": "Organization profile (trailing slash)" | ||
| } | ||
| ] | ||
| } | ||
| ] | ||
| }, | ||
| "webcredentials": { | ||
| "apps": ["X96D76J65Z.com.kilocode.kiloapp"] | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| [ | ||
| { | ||
| "relation": ["delegate_permission/common.handle_all_urls"], | ||
| "target": { | ||
| "namespace": "android_app", | ||
| "package_name": "com.kilocode.kiloapp", | ||
| "sha256_cert_fingerprints": [ | ||
| "39:87:0D:39:0E:45:88:4F:B8:B0:2D:A5:0C:E4:97:9B:EC:67:B2:CF:5F:69:D9:A8:84:79:5E:65:FD:B8:85:E7" | ||
| ] | ||
| } | ||
| } | ||
| ] |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
WARNING: Personal deep links never reset org context
link.organizationIdis the only branch that callssetContext, so/clawand/profilenow reuse whatever organization context was already active. If someone is currently scoped to an org and openshttps://app.kilo.ai/claworhttps://app.kilo.ai/profile, this code navigates to the screen but leaves that org selected, so they never reach the personal page those URLs represent.