-
Notifications
You must be signed in to change notification settings - Fork 46
feat: Smart App Banner deep-linking + universal links (iOS) and App Links (Android) for app.kilo.ai #4783
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
Merged
Merged
feat: Smart App Banner deep-linking + universal links (iOS) and App Links (Android) for app.kilo.ai #4783
Changes from all commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
97b0420
feat(web): add Apple Smart App Banner meta tag
iscekic 6c729fe
feat(app-shared): add universal link route map
iscekic 12c08e2
feat(web): serve app site association and asset links for deep linking
iscekic 4e9d5d9
feat(web): deep-link the smart app banner open button
iscekic 14742fa
feat(mobile): associate app.kilo.ai domain for universal and app links
iscekic 980d090
feat(mobile): route incoming web links to matching app screens
iscekic 9383120
fix(app-shared): drop unnecessary non-null assertions in route matcher
iscekic 13ac1c1
fix(app-shared): match route segments without indexed access
iscekic c6a1a0d
fix(app-shared): substitute route captures literally
iscekic 4ac8256
fix(mobile): skip cold restash after synchronous launch capture
iscekic 9202b5f
fix(app-shared): substitute route captures in a single template pass
iscekic 580ce33
Merge origin/main into feat/web-smart-app-banner
iscekic 037353d
test(mobile): split agent-push-preference tests under the max-lines cap
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
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,167 @@ | ||
| import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; | ||
|
|
||
| import type * as UniversalLinks from '@kilocode/app-shared/universal-links'; | ||
|
|
||
| import { redirectSystemPath } from './deep-link-handler'; | ||
| import { | ||
| _resetDeepLinkLaunchForTests, | ||
| _setGetLinkingURLForTests, | ||
| captureLaunchDeepLink, | ||
| getPendingDeepLink, | ||
| } from './deep-link-launch'; | ||
|
|
||
| const mocks = vi.hoisted(() => ({ | ||
| navigate: vi.fn(), | ||
| shouldThrow: false, | ||
| })); | ||
|
|
||
| vi.mock('expo-router', () => ({ | ||
| router: { | ||
| navigate: mocks.navigate, | ||
| }, | ||
| })); | ||
|
|
||
| vi.mock('@kilocode/app-shared/universal-links', async importOriginal => { | ||
| const actual = await importOriginal<typeof UniversalLinks>(); | ||
| return { | ||
| ...actual, | ||
| resolveIncomingUrl: (raw: string) => { | ||
| if (mocks.shouldThrow) { | ||
| throw new Error('boom'); | ||
| } | ||
| return actual.resolveIncomingUrl(raw); | ||
| }, | ||
| }; | ||
| }); | ||
|
|
||
| const MAPPED_CASES = [ | ||
| { | ||
| path: 'https://app.kilo.ai/profile', | ||
| href: '/(app)/(tabs)/(3_profile)', | ||
| }, | ||
| { | ||
| path: 'https://app.kilo.ai/security-agent/findings', | ||
| href: '/(app)/(tabs)/(3_profile)/security-agent/personal/findings', | ||
| }, | ||
| { | ||
| path: 'https://app.kilo.ai/code-reviews/rev_9', | ||
| href: '/(app)/(tabs)/(3_profile)/code-reviewer/personal/reviews/rev_9', | ||
| }, | ||
| ] as const; | ||
|
|
||
| describe('redirectSystemPath', () => { | ||
| beforeEach(() => { | ||
| _resetDeepLinkLaunchForTests(); | ||
| mocks.navigate.mockReset(); | ||
| mocks.shouldThrow = false; | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| _resetDeepLinkLaunchForTests(); | ||
| mocks.shouldThrow = false; | ||
| }); | ||
|
|
||
| describe('cold invariant', () => { | ||
| it.each(MAPPED_CASES)('stashes $path and does not navigate', ({ path, href }) => { | ||
| const result = redirectSystemPath({ path, initial: true }); | ||
| expect(result).toBeNull(); | ||
| expect(!result).toBe(true); | ||
| expect(getPendingDeepLink()).toBe(href); | ||
| expect(mocks.navigate).not.toHaveBeenCalled(); | ||
| }); | ||
| }); | ||
|
|
||
| describe('warm invariant', () => { | ||
| it.each(MAPPED_CASES)('navigates $path and leaves pending empty', ({ path, href }) => { | ||
| const result = redirectSystemPath({ path, initial: false }); | ||
| expect(result).toBeNull(); | ||
| expect(!result).toBe(true); | ||
| expect(mocks.navigate).toHaveBeenCalledOnce(); | ||
| expect(mocks.navigate).toHaveBeenCalledWith(href); | ||
| expect(getPendingDeepLink()).toBeNull(); | ||
| }); | ||
| }); | ||
|
|
||
| describe('passthrough', () => { | ||
| it.each([ | ||
| 'https://app.kilo.ai/admin', | ||
| 'https://app.kilo.ai/code-reviews/review-md', | ||
| 'https://example.com/profile', | ||
| 'not a url', | ||
| ])('returns %s unchanged without navigate or stash', path => { | ||
| const result = redirectSystemPath({ path, initial: true }); | ||
| expect(result).toBe(path); | ||
| expect(getPendingDeepLink()).toBeNull(); | ||
| expect(mocks.navigate).not.toHaveBeenCalled(); | ||
|
|
||
| const warm = redirectSystemPath({ path, initial: false }); | ||
| expect(warm).toBe(path); | ||
| expect(getPendingDeepLink()).toBeNull(); | ||
| expect(mocks.navigate).not.toHaveBeenCalled(); | ||
| }); | ||
| }); | ||
|
|
||
| describe('kiloapp:// forms', () => { | ||
| it('cold kiloapp:///profile stashes group href and returns null', () => { | ||
| const result = redirectSystemPath({ path: 'kiloapp:///profile', initial: true }); | ||
| expect(result).toBeNull(); | ||
| expect(!result).toBe(true); | ||
| expect(getPendingDeepLink()).toBe('/(app)/(tabs)/(3_profile)'); | ||
| expect(mocks.navigate).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('warm kiloapp://profile navigates group href and returns null', () => { | ||
| const result = redirectSystemPath({ path: 'kiloapp://profile', initial: false }); | ||
| expect(result).toBeNull(); | ||
| expect(!result).toBe(true); | ||
| expect(mocks.navigate).toHaveBeenCalledOnce(); | ||
| expect(mocks.navigate).toHaveBeenCalledWith('/(app)/(tabs)/(3_profile)'); | ||
| expect(getPendingDeepLink()).toBeNull(); | ||
| }); | ||
| }); | ||
|
|
||
| describe('launch-capture dedup', () => { | ||
| it('cold initial does not restash when the launch capture already stashed the link', () => { | ||
| _setGetLinkingURLForTests(() => 'kiloapp:///profile'); | ||
| captureLaunchDeepLink(); | ||
| // The gate effect can consume the slot before expo-router's cold path resolves. | ||
| expect(getPendingDeepLink()).toBe('/(app)/(tabs)/(3_profile)'); | ||
| const result = redirectSystemPath({ path: 'kiloapp:///profile', initial: true }); | ||
| expect(result).toBeNull(); | ||
| // No restash — a later, unrelated effect re-run must find the slot empty. | ||
| expect(getPendingDeepLink()).toBeNull(); | ||
| expect(mocks.navigate).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('cold initial still stashes when the launch capture found no link', () => { | ||
| _setGetLinkingURLForTests(() => null); | ||
| captureLaunchDeepLink(); | ||
| const result = redirectSystemPath({ path: 'kiloapp:///profile', initial: true }); | ||
| expect(result).toBeNull(); | ||
| expect(getPendingDeepLink()).toBe('/(app)/(tabs)/(3_profile)'); | ||
| expect(mocks.navigate).not.toHaveBeenCalled(); | ||
| }); | ||
| }); | ||
|
|
||
| describe('synchronicity', () => { | ||
| it('return value is not a Promise', () => { | ||
| const result = redirectSystemPath({ | ||
| path: 'https://app.kilo.ai/profile', | ||
| initial: true, | ||
| }); | ||
| expect(result).not.toBeInstanceOf(Promise); | ||
| expect(result).toBeNull(); | ||
| }); | ||
| }); | ||
|
|
||
| describe('try/catch', () => { | ||
| it('returns path unchanged when resolveIncomingUrl throws', () => { | ||
| mocks.shouldThrow = true; | ||
| const path = 'https://app.kilo.ai/profile'; | ||
| const result = redirectSystemPath({ path, initial: true }); | ||
| expect(result).toBe(path); | ||
| expect(getPendingDeepLink()).toBeNull(); | ||
| expect(mocks.navigate).not.toHaveBeenCalled(); | ||
| }); | ||
| }); | ||
| }); |
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 @@ | ||
| import { type Href, router } from 'expo-router'; | ||
|
|
||
| import { resolveIncomingUrl } from '@kilocode/app-shared/universal-links'; | ||
|
|
||
| import { setPendingDeepLink, wasLaunchLinkHandled } from './deep-link-launch'; | ||
|
|
||
| /** | ||
| * expo-router `+native-intent` `redirectSystemPath` implementation. | ||
| * | ||
| * Load-bearing facts (past critical findings): | ||
| * 1. SYNCHRONOUS — expo-router's cold path assigns the result without `await`. | ||
| * 2. Must return FALSY for handled links — a truthy return re-dispatches the | ||
| * linking resolver and races our navigation against a reset-to-Home. | ||
| * Returning `'/'` is a bug that looks like success on tab-root rows. | ||
| * 3. `initial` is the cold/warm discriminator — never try/catch around navigate | ||
| * as a readiness probe; `router.navigate` queues rather than throws when the | ||
| * router is unmounted, so try/catch silently drops cold deep links. | ||
| */ | ||
| export function redirectSystemPath({ | ||
| path, | ||
| initial, | ||
| }: { | ||
| path: string; | ||
| initial: boolean; | ||
| }): string | null { | ||
| try { | ||
| const href = resolveIncomingUrl(path); | ||
| // Untouched → default handling (and future share intent). | ||
| if (href == null) { | ||
| return path; | ||
| } | ||
| if (initial) { | ||
| // COLD: stash only. Never navigate — router isn't mounted. | ||
| // Skip when the synchronous launch capture already stashed this launch | ||
| // URL: expo-router's cold path can land AFTER the gate effect consumed | ||
| // the slot, and a restash would surface as a duplicate navigation on a | ||
| // later, unrelated effect re-run (e.g. token refresh). | ||
| if (!wasLaunchLinkHandled()) { | ||
| setPendingDeepLink(href, 'universal-link'); | ||
| } | ||
| } else { | ||
| // WARM: router is mounted; group hrefs work here. | ||
| router.navigate(href as Href); | ||
| } | ||
| // Falsy in both handled cases — critical (see above). | ||
| return null; | ||
| } catch { | ||
| // A deep-link bug must never brick app launch. | ||
| 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,87 @@ | ||
| import { afterEach, beforeEach, describe, expect, it } from 'vitest'; | ||
|
|
||
| import { | ||
| _resetDeepLinkLaunchForTests, | ||
| _setGetLinkingURLForTests, | ||
| captureLaunchDeepLink, | ||
| getPendingDeepLink, | ||
| setPendingDeepLink, | ||
| } from './deep-link-launch'; | ||
|
|
||
| describe('deep-link-launch', () => { | ||
| beforeEach(() => { | ||
| _resetDeepLinkLaunchForTests(); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| _resetDeepLinkLaunchForTests(); | ||
| }); | ||
|
|
||
| describe('pending slot', () => { | ||
| it('is single-shot get-and-clear', () => { | ||
| setPendingDeepLink('/(app)/(tabs)/(3_profile)', 'universal-link'); | ||
| expect(getPendingDeepLink()).toBe('/(app)/(tabs)/(3_profile)'); | ||
| expect(getPendingDeepLink()).toBeNull(); | ||
| }); | ||
| }); | ||
|
|
||
| describe('source precedence (order-independent)', () => { | ||
| it('notification then universal-link leaves the link href', () => { | ||
| setPendingDeepLink('/from-notification', 'notification'); | ||
| setPendingDeepLink('/from-link', 'universal-link'); | ||
| expect(getPendingDeepLink()).toBe('/from-link'); | ||
| }); | ||
|
|
||
| it('universal-link then notification leaves the link href', () => { | ||
| setPendingDeepLink('/from-link', 'universal-link'); | ||
| setPendingDeepLink('/from-notification', 'notification'); | ||
| expect(getPendingDeepLink()).toBe('/from-link'); | ||
| }); | ||
|
|
||
| it('notification then notification leaves the latest notification href', () => { | ||
| setPendingDeepLink('/notif-1', 'notification'); | ||
| setPendingDeepLink('/notif-2', 'notification'); | ||
| expect(getPendingDeepLink()).toBe('/notif-2'); | ||
| }); | ||
| }); | ||
|
|
||
| describe('captureLaunchDeepLink', () => { | ||
| it('stashes a mapped launch URL synchronously', () => { | ||
| _setGetLinkingURLForTests(() => 'https://app.kilo.ai/security-agent/findings'); | ||
| captureLaunchDeepLink(); | ||
| // Assert immediately — no await. The point of the test is synchronicity. | ||
| expect(getPendingDeepLink()).toBe( | ||
| '/(app)/(tabs)/(3_profile)/security-agent/personal/findings' | ||
| ); | ||
| }); | ||
|
|
||
| it('is a no-op when the latch is already set (slot not overwritten)', () => { | ||
| _setGetLinkingURLForTests(() => 'https://app.kilo.ai/profile'); | ||
| captureLaunchDeepLink(); | ||
| expect(getPendingDeepLink()).toBe('/(app)/(tabs)/(3_profile)'); | ||
|
|
||
| // Second call must not write again even if getLinkingURL returns a new URL. | ||
| _setGetLinkingURLForTests(() => 'https://app.kilo.ai/claw'); | ||
| setPendingDeepLink('/pre-existing', 'notification'); | ||
| captureLaunchDeepLink(); | ||
| expect(getPendingDeepLink()).toBe('/pre-existing'); | ||
| }); | ||
|
|
||
| it('is a no-op when getLinkingURL returns null', () => { | ||
| _setGetLinkingURLForTests(() => null); | ||
| captureLaunchDeepLink(); | ||
| expect(getPendingDeepLink()).toBeNull(); | ||
| }); | ||
|
|
||
| it('is a no-op for an unmapped/garbage URL', () => { | ||
| _setGetLinkingURLForTests(() => 'https://app.kilo.ai/admin'); | ||
| captureLaunchDeepLink(); | ||
| expect(getPendingDeepLink()).toBeNull(); | ||
|
|
||
| // Latch only sets on a successful mapped capture; garbage still no-ops. | ||
| _setGetLinkingURLForTests(() => 'not a url'); | ||
| captureLaunchDeepLink(); | ||
| expect(getPendingDeepLink()).toBeNull(); | ||
| }); | ||
| }); | ||
| }); |
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.
Uh oh!
There was an error while loading. Please reload this page.