diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 7b41403a8f..d210493a3b 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -1,5 +1,6 @@ import type { ExpoConfig } from 'expo/config'; import { ENV_KEYS, OPTIONAL_ENV_KEYS } from './src/lib/env-keys'; +import { UNIVERSAL_LINK_PATH_PATTERNS } from './src/lib/universal-link-paths'; const missing = Object.values(ENV_KEYS).filter(key => !process.env[key]); if (missing.length > 0) { @@ -36,6 +37,7 @@ const config: ExpoConfig = { requireFullScreen: true, supportsTablet: true, usesAppleSignIn: true, + associatedDomains: ['applinks:app.kilo.ai'], infoPlist: { ITSAppUsesNonExemptEncryption: false, NSAdvertisingAttributionReportEndpoint: 'https://appsflyer-skadnetwork.com/', @@ -71,6 +73,18 @@ const config: ExpoConfig = { 'android.permission.READ_MEDIA_VIDEO', 'android.permission.READ_MEDIA_AUDIO', ], + intentFilters: [ + { + action: 'VIEW', + autoVerify: true, + data: UNIVERSAL_LINK_PATH_PATTERNS.map(pathPattern => ({ + scheme: 'https', + host: 'app.kilo.ai', + pathPattern, + })), + category: ['BROWSABLE', 'DEFAULT'], + }, + ], }, plugins: [ ['expo-dev-client', { toolsButton: false }], diff --git a/apps/mobile/src/app/+native-intent.tsx b/apps/mobile/src/app/+native-intent.tsx index 58bb222ddf..afecd7f10a 100644 --- a/apps/mobile/src/app/+native-intent.tsx +++ b/apps/mobile/src/app/+native-intent.tsx @@ -1,5 +1,9 @@ import { getShareExtensionKey } from 'expo-share-intent'; +import { redirectSystemPath as mapWebPath } from '@/lib/deep-link-handler'; + +// Composes both native-intent concerns: the share-extension check must return +// early before web-path mapping (a share URL is never a web route). export function redirectSystemPath({ path, initial }: { path: string; initial: boolean }) { let shareKey: string | null = null; try { @@ -11,5 +15,5 @@ export function redirectSystemPath({ path, initial }: { path: string; initial: b // Cold start: boot the app normally. Warm: stay exactly where the user is. return initial ? '/' : null; } - return path; + return mapWebPath({ path, initial }); } diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 2598e63ce4..acb510cd09 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -39,13 +39,13 @@ import { useScreenTracking } from '@/lib/hooks/use-screen-tracking'; import { useNavigationTheme } from '@/lib/hooks/use-theme-colors'; import { applyThemePreference, useThemePreference } from '@/lib/hooks/use-theme-preference'; import { useTrackingPermissionPrompt } from '@/lib/hooks/use-tracking-permission-prompt'; +import { captureLaunchDeepLink, getPendingDeepLink } from '@/lib/deep-link-launch'; import { checkInitialNotification, - getPendingNotificationLink, setupNotificationHandler, setupNotificationResponseHandler, } from '@/lib/notifications'; -import { resolvePendingNotificationNavigation } from '@/lib/pending-notification-navigation'; +import { resolvePendingNavigation } from '@/lib/pending-navigation'; import { isShellReadyForShare, resolvePendingShareNavigation, @@ -98,6 +98,7 @@ initSentry(false); void SplashScreen.preventAutoHideAsync(); setupNotificationHandler(); checkInitialNotification(); +captureLaunchDeepLink(); function RootLayoutNav() { const { token, isLoading: authLoading, signOut } = useAuth(); @@ -341,8 +342,8 @@ function RootLayoutNav() { } void SplashScreen.hideAsync(); - // Navigate to pending notification deep link (cold start / background tap) - const pendingNavigation = resolvePendingNotificationNavigation(getPendingNotificationLink()); + // Navigate to pending deep link (cold start universal link / notification tap) + const pendingNavigation = resolvePendingNavigation(getPendingDeepLink()); if (pendingNavigation) { router.navigate(pendingNavigation.href as Href); } diff --git a/apps/mobile/src/lib/deep-link-handler.test.ts b/apps/mobile/src/lib/deep-link-handler.test.ts new file mode 100644 index 0000000000..f36297464f --- /dev/null +++ b/apps/mobile/src/lib/deep-link-handler.test.ts @@ -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(); + 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(); + }); + }); +}); diff --git a/apps/mobile/src/lib/deep-link-handler.ts b/apps/mobile/src/lib/deep-link-handler.ts new file mode 100644 index 0000000000..bbbaa4e9a6 --- /dev/null +++ b/apps/mobile/src/lib/deep-link-handler.ts @@ -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; + } +} diff --git a/apps/mobile/src/lib/deep-link-launch.test.ts b/apps/mobile/src/lib/deep-link-launch.test.ts new file mode 100644 index 0000000000..977915694d --- /dev/null +++ b/apps/mobile/src/lib/deep-link-launch.test.ts @@ -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(); + }); + }); +}); diff --git a/apps/mobile/src/lib/deep-link-launch.ts b/apps/mobile/src/lib/deep-link-launch.ts new file mode 100644 index 0000000000..1244ffaee5 --- /dev/null +++ b/apps/mobile/src/lib/deep-link-launch.ts @@ -0,0 +1,100 @@ +import { resolveIncomingUrl } from '@kilocode/app-shared/universal-links'; + +type DeepLinkSource = 'universal-link' | 'notification'; + +type GetLinkingURL = () => string | null; + +let pendingDeepLink: string | null = null; +let pendingSource: DeepLinkSource | null = null; +let launchLinkHandled = false; + +// Test-only override so captureLaunchDeepLink can stay synchronous without +// pulling expo-linking (→ RN) into suites that only touch the pending slot. +let getLinkingURLForTests: GetLinkingURL | null = null; + +/** + * Stash a deep-link href for the root layout to consume after gates clear. + * Source is required so the type checker enforces precedence: + * - `'universal-link'` always wins (overwrites anything). + * - `'notification'` applies only when the slot is empty or already a notification. + * Rationale: `getLastNotificationResponse()` can return a *stale* response on a + * launch actually caused by a link, so the link is the better evidence of what + * started this process. + */ +export function setPendingDeepLink(href: string, source: DeepLinkSource): void { + if (source === 'universal-link') { + pendingDeepLink = href; + pendingSource = source; + return; + } + // notification + if (pendingDeepLink === null || pendingSource === 'notification') { + pendingDeepLink = href; + pendingSource = source; + } +} + +/** Get-and-clear. Single consumer is `_layout.tsx`. */ +export function getPendingDeepLink(): string | null { + const href = pendingDeepLink; + pendingDeepLink = null; + pendingSource = null; + return href; +} + +function readLaunchUrl(): string | null { + if (getLinkingURLForTests) { + return getLinkingURLForTests(); + } + // Synchronous native read. Lazy require so modules that only use the pending + // slot (e.g. notifications → unread-counts tests) do not load expo-linking at + // import time. Static `import` would pull RN into those unit-test graphs. + // eslint-disable-next-line typescript-eslint/no-require-imports, typescript-eslint/no-var-requires, unicorn/prefer-module -- sync launch capture; see comment above + const linking = require('expo-linking') as { getLinkingURL: GetLinkingURL }; + return linking.getLinkingURL(); +} + +/** + * SYNCHRONOUS capture of the OS launch URL into the pending slot. + * Called at `_layout.tsx` module scope so the slot is populated before any effect. + * + * Why `getLinkingURL()` (not `getInitialURL()`): expo-router's Android cold path + * races `Linking.getInitialURL()` against a 150ms timeout and substitutes the app + * root URL on timeout, so the launch URL can vanish with no error. `getLinkingURL()` + * is native-populated at activity `onCreate` and cannot be lost that way. No + * `Platform.OS` check — it is the correct source on both platforms. + * + * Do NOT call `clearInitialURL()` — expo-router's own cold path reads the same value. + */ +export function captureLaunchDeepLink(): void { + if (launchLinkHandled) { + return; + } + const url = readLaunchUrl(); + if (!url) { + return; + } + const href = resolveIncomingUrl(url); + if (href) { + setPendingDeepLink(href, 'universal-link'); + launchLinkHandled = true; + } +} + +/** Whether the synchronous launch capture already stashed this process's launch link. */ +export function wasLaunchLinkHandled(): boolean { + return launchLinkHandled; +} + +/** Test-only: reset module-private latch and pending slot between cases. */ +export function _resetDeepLinkLaunchForTests(): void { + pendingDeepLink = null; + pendingSource = null; + launchLinkHandled = false; + getLinkingURLForTests = null; +} + +/** Test-only: stub the synchronous launch-URL reader without loading expo-linking. */ +export function _setGetLinkingURLForTests(fn: GetLinkingURL | null): void { + getLinkingURLForTests = fn; +} diff --git a/apps/mobile/src/lib/hooks/agent-push-preference.optimistic.test.ts b/apps/mobile/src/lib/hooks/agent-push-preference.optimistic.test.ts new file mode 100644 index 0000000000..8bc13f7362 --- /dev/null +++ b/apps/mobile/src/lib/hooks/agent-push-preference.optimistic.test.ts @@ -0,0 +1,227 @@ +import { QueryClient } from '@tanstack/react-query'; +import { describe, expect, it } from 'vitest'; + +import { + applyAgentPushOptimistic, + DEFAULT_NOTIFICATION_PREFERENCE, + NOTIFICATION_CATEGORY_KEYS, + type NotificationCategoryKey, + type NotificationPreferences, + rollbackAgentPushOptimistic, +} from './agent-push-preference'; + +const key = ['user', 'getNotificationPreferences'] as const; + +function makeQueryClient(): QueryClient { + return new QueryClient(); +} + +function readRow(qc: QueryClient): NotificationPreferences { + const data = qc.getQueryData(key); + if (!data) { + throw new Error('expected query data to be present'); + } + return data as NotificationPreferences; +} + +function fullRow(overrides: Partial = {}): NotificationPreferences { + return { + chatMessages: DEFAULT_NOTIFICATION_PREFERENCE, + agentAttention: DEFAULT_NOTIFICATION_PREFERENCE, + agentUpdates: DEFAULT_NOTIFICATION_PREFERENCE, + sessionStatus: DEFAULT_NOTIFICATION_PREFERENCE, + kiloclawActivity: DEFAULT_NOTIFICATION_PREFERENCE, + balanceAlerts: DEFAULT_NOTIFICATION_PREFERENCE, + securityFindings: DEFAULT_NOTIFICATION_PREFERENCE, + agentPushEnabled: DEFAULT_NOTIFICATION_PREFERENCE, + ...overrides, + }; +} + +describe('applyAgentPushOptimistic + rollbackAgentPushOptimistic (per-category)', () => { + it('flips the requested category and leaves the others unchanged', async () => { + const qc = makeQueryClient(); + qc.setQueryData(key, fullRow({ agentAttention: true, sessionStatus: true })); + + const context = await applyAgentPushOptimistic({ + queryClient: qc, + queryKey: key, + next: false, + category: 'agentAttention', + }); + + const after = readRow(qc); + expect(after.agentAttention).toBe(false); + expect(after.sessionStatus).toBe(true); + expect(after.agentUpdates).toBe(DEFAULT_NOTIFICATION_PREFERENCE); + expect(context.previous).toEqual(fullRow({ agentAttention: true, sessionStatus: true })); + expect(context.previousWasLegacy).toBe(false); + }); + + it('flips a non-agentUpdates category in a real full snapshot without corrupting the other keys', async () => { + const qc = makeQueryClient(); + const original = { + chatMessages: true, + agentAttention: true, + agentUpdates: true, + sessionStatus: false, + kiloclawActivity: true, + balanceAlerts: true, + securityFindings: true, + agentPushEnabled: true, + } as const satisfies NotificationPreferences; + qc.setQueryData(key, original); + + const context = await applyAgentPushOptimistic({ + queryClient: qc, + queryKey: key, + next: true, + category: 'sessionStatus', + }); + + const after = readRow(qc); + expect(after.sessionStatus).toBe(true); + expect(after.chatMessages).toBe(true); + expect(after.agentAttention).toBe(true); + expect(after.agentUpdates).toBe(true); + expect(after.kiloclawActivity).toBe(true); + expect(after.balanceAlerts).toBe(true); + expect(after.securityFindings).toBe(true); + expect(after.agentPushEnabled).toBe(true); + + expect(context.previous).toEqual(original); + expect(context.previousWasLegacy).toBe(false); + + rollbackAgentPushOptimistic({ queryClient: qc, queryKey: key, context }); + expect(qc.getQueryData(key)).toEqual(original); + }); + + it('rolls back to the previous snapshot on error', async () => { + const qc = makeQueryClient(); + qc.setQueryData(key, fullRow({ agentAttention: true })); + + const context = await applyAgentPushOptimistic({ + queryClient: qc, + queryKey: key, + next: false, + category: 'agentAttention', + }); + expect(readRow(qc).agentAttention).toBe(false); + + rollbackAgentPushOptimistic({ queryClient: qc, queryKey: key, context }); + expect(qc.getQueryData(key)).toEqual(fullRow({ agentAttention: true })); + }); + + it('rolls back from a default-ON starting state (no prior cache entry)', async () => { + const qc = makeQueryClient(); + expect(qc.getQueryData(key)).toBeUndefined(); + + const context = await applyAgentPushOptimistic({ + queryClient: qc, + queryKey: key, + next: false, + category: 'agentUpdates', + }); + // No prior cache entry => previous is undefined; the optimistic write + // materializes the full row with the flipped value so the row reads + // consistently while the mutation is in flight. + expect(context.previous).toBeUndefined(); + expect(readRow(qc).agentUpdates).toBe(false); + expect(readRow(qc).balanceAlerts).toBe(DEFAULT_NOTIFICATION_PREFERENCE); + expect(readRow(qc).securityFindings).toBe(DEFAULT_NOTIFICATION_PREFERENCE); + + rollbackAgentPushOptimistic({ queryClient: qc, queryKey: key, context }); + // No prior snapshot => cache restored to the absent state, not a fabricated true. + expect(qc.getQueryData(key)).toBeUndefined(); + }); + + it('rolls back each category independently and removes the empty-cache entry', async () => { + await Promise.all( + NOTIFICATION_CATEGORY_KEYS.map(async category => { + const qc = makeQueryClient(); + const context = await applyAgentPushOptimistic({ + queryClient: qc, + queryKey: key, + next: false, + category, + }); + expect(readRow(qc)[category]).toBe(false); + rollbackAgentPushOptimistic({ queryClient: qc, queryKey: key, context }); + expect(qc.getQueryData(key)).toBeUndefined(); + }) + ); + }); + + it('preserves the legacy `agentPushEnabled`-only snapshot exactly on rollback', async () => { + const qc = makeQueryClient(); + const legacy = { agentPushEnabled: true } as const; + qc.setQueryData(key, legacy); + + const context = await applyAgentPushOptimistic({ + queryClient: qc, + queryKey: key, + next: false, + category: 'agentAttention', + }); + // After applying, the cache holds the promoted per-category shape with + // agentAttention flipped to false and agentUpdates carrying the legacy + // value (true). + const after = readRow(qc); + expect(after.agentAttention).toBe(false); + expect(after.agentUpdates).toBe(true); + expect(after.balanceAlerts).toBe(DEFAULT_NOTIFICATION_PREFERENCE); + expect(after.securityFindings).toBe(DEFAULT_NOTIFICATION_PREFERENCE); + expect(context.previous).toBe(legacy); + expect(context.previousWasLegacy).toBe(true); + + rollbackAgentPushOptimistic({ queryClient: qc, queryKey: key, context }); + // Rollback must restore the exact legacy shape, not the promoted one. + expect(qc.getQueryData(key)).toEqual(legacy); + }); + + it('treats an undefined context as a no-op rollback (defensive against missing context)', () => { + const qc = makeQueryClient(); + qc.setQueryData(key, fullRow({ agentAttention: true })); + expect(() => { + rollbackAgentPushOptimistic({ queryClient: qc, queryKey: key, context: undefined }); + }).not.toThrow(); + // The cache is left intact when no context is provided. + expect(qc.getQueryData(key)).toEqual(fullRow({ agentAttention: true })); + }); +}); + +describe('per-category flip flow (each category in turn)', () => { + const scenarios: { category: NotificationCategoryKey; next: boolean }[] = [ + { category: 'chatMessages', next: false }, + { category: 'agentAttention', next: true }, + { category: 'agentUpdates', next: false }, + { category: 'sessionStatus', next: true }, + { category: 'kiloclawActivity', next: false }, + { category: 'balanceAlerts', next: false }, + { category: 'securityFindings', next: true }, + ]; + + for (const { category, next } of scenarios) { + it(`flips only ${category} → ${next} and rolls back cleanly`, async () => { + const qc = makeQueryClient(); + qc.setQueryData(key, fullRow()); + + const context = await applyAgentPushOptimistic({ + queryClient: qc, + queryKey: key, + next, + category, + }); + const after = readRow(qc); + expect(after[category]).toBe(next); + for (const other of NOTIFICATION_CATEGORY_KEYS) { + if (other !== category) { + expect(after[other]).toBe(DEFAULT_NOTIFICATION_PREFERENCE); + } + } + + rollbackAgentPushOptimistic({ queryClient: qc, queryKey: key, context }); + expect(qc.getQueryData(key)).toEqual(fullRow()); + }); + } +}); diff --git a/apps/mobile/src/lib/hooks/agent-push-preference.test.ts b/apps/mobile/src/lib/hooks/agent-push-preference.test.ts index 85e8812fc0..f6d211821b 100644 --- a/apps/mobile/src/lib/hooks/agent-push-preference.test.ts +++ b/apps/mobile/src/lib/hooks/agent-push-preference.test.ts @@ -2,16 +2,13 @@ import { QueryClient } from '@tanstack/react-query'; import { describe, expect, it } from 'vitest'; import { - applyAgentPushOptimistic, DEFAULT_NOTIFICATION_PREFERENCE, deriveAgentPushEditable, deriveGateSettled, deriveShowEnableCta, NOTIFICATION_CATEGORY_KEYS, - type NotificationCategoryKey, type NotificationPreferences, readAgentPushPreference, - rollbackAgentPushOptimistic, } from './agent-push-preference'; const key = ['user', 'getNotificationPreferences'] as const; @@ -20,14 +17,6 @@ function makeQueryClient(): QueryClient { return new QueryClient(); } -function readRow(qc: QueryClient): NotificationPreferences { - const data = qc.getQueryData(key); - if (!data) { - throw new Error('expected query data to be present'); - } - return data as NotificationPreferences; -} - function fullRow(overrides: Partial = {}): NotificationPreferences { return { chatMessages: DEFAULT_NOTIFICATION_PREFERENCE, @@ -174,191 +163,3 @@ describe('readAgentPushPreference', () => { expect(readAgentPushPreference(qc, key)).toBe(false); }); }); - -describe('applyAgentPushOptimistic + rollbackAgentPushOptimistic (per-category)', () => { - it('flips the requested category and leaves the others unchanged', async () => { - const qc = makeQueryClient(); - qc.setQueryData(key, fullRow({ agentAttention: true, sessionStatus: true })); - - const context = await applyAgentPushOptimistic({ - queryClient: qc, - queryKey: key, - next: false, - category: 'agentAttention', - }); - - const after = readRow(qc); - expect(after.agentAttention).toBe(false); - expect(after.sessionStatus).toBe(true); - expect(after.agentUpdates).toBe(DEFAULT_NOTIFICATION_PREFERENCE); - expect(context.previous).toEqual(fullRow({ agentAttention: true, sessionStatus: true })); - expect(context.previousWasLegacy).toBe(false); - }); - - it('flips a non-agentUpdates category in a real full snapshot without corrupting the other keys', async () => { - const qc = makeQueryClient(); - const original = { - chatMessages: true, - agentAttention: true, - agentUpdates: true, - sessionStatus: false, - kiloclawActivity: true, - balanceAlerts: true, - securityFindings: true, - agentPushEnabled: true, - } as const satisfies NotificationPreferences; - qc.setQueryData(key, original); - - const context = await applyAgentPushOptimistic({ - queryClient: qc, - queryKey: key, - next: true, - category: 'sessionStatus', - }); - - const after = readRow(qc); - expect(after.sessionStatus).toBe(true); - expect(after.chatMessages).toBe(true); - expect(after.agentAttention).toBe(true); - expect(after.agentUpdates).toBe(true); - expect(after.kiloclawActivity).toBe(true); - expect(after.balanceAlerts).toBe(true); - expect(after.securityFindings).toBe(true); - expect(after.agentPushEnabled).toBe(true); - - expect(context.previous).toEqual(original); - expect(context.previousWasLegacy).toBe(false); - - rollbackAgentPushOptimistic({ queryClient: qc, queryKey: key, context }); - expect(qc.getQueryData(key)).toEqual(original); - }); - - it('rolls back to the previous snapshot on error', async () => { - const qc = makeQueryClient(); - qc.setQueryData(key, fullRow({ agentAttention: true })); - - const context = await applyAgentPushOptimistic({ - queryClient: qc, - queryKey: key, - next: false, - category: 'agentAttention', - }); - expect(readRow(qc).agentAttention).toBe(false); - - rollbackAgentPushOptimistic({ queryClient: qc, queryKey: key, context }); - expect(qc.getQueryData(key)).toEqual(fullRow({ agentAttention: true })); - }); - - it('rolls back from a default-ON starting state (no prior cache entry)', async () => { - const qc = makeQueryClient(); - expect(qc.getQueryData(key)).toBeUndefined(); - - const context = await applyAgentPushOptimistic({ - queryClient: qc, - queryKey: key, - next: false, - category: 'agentUpdates', - }); - // No prior cache entry => previous is undefined; the optimistic write - // materializes the full row with the flipped value so the row reads - // consistently while the mutation is in flight. - expect(context.previous).toBeUndefined(); - expect(readRow(qc).agentUpdates).toBe(false); - expect(readRow(qc).balanceAlerts).toBe(DEFAULT_NOTIFICATION_PREFERENCE); - expect(readRow(qc).securityFindings).toBe(DEFAULT_NOTIFICATION_PREFERENCE); - - rollbackAgentPushOptimistic({ queryClient: qc, queryKey: key, context }); - // No prior snapshot => cache restored to the absent state, not a fabricated true. - expect(qc.getQueryData(key)).toBeUndefined(); - }); - - it('rolls back each category independently and removes the empty-cache entry', async () => { - await Promise.all( - NOTIFICATION_CATEGORY_KEYS.map(async category => { - const qc = makeQueryClient(); - const context = await applyAgentPushOptimistic({ - queryClient: qc, - queryKey: key, - next: false, - category, - }); - expect(readRow(qc)[category]).toBe(false); - rollbackAgentPushOptimistic({ queryClient: qc, queryKey: key, context }); - expect(qc.getQueryData(key)).toBeUndefined(); - }) - ); - }); - - it('preserves the legacy `agentPushEnabled`-only snapshot exactly on rollback', async () => { - const qc = makeQueryClient(); - const legacy = { agentPushEnabled: true } as const; - qc.setQueryData(key, legacy); - - const context = await applyAgentPushOptimistic({ - queryClient: qc, - queryKey: key, - next: false, - category: 'agentAttention', - }); - // After applying, the cache holds the promoted per-category shape with - // agentAttention flipped to false and agentUpdates carrying the legacy - // value (true). - const after = readRow(qc); - expect(after.agentAttention).toBe(false); - expect(after.agentUpdates).toBe(true); - expect(after.balanceAlerts).toBe(DEFAULT_NOTIFICATION_PREFERENCE); - expect(after.securityFindings).toBe(DEFAULT_NOTIFICATION_PREFERENCE); - expect(context.previous).toBe(legacy); - expect(context.previousWasLegacy).toBe(true); - - rollbackAgentPushOptimistic({ queryClient: qc, queryKey: key, context }); - // Rollback must restore the exact legacy shape, not the promoted one. - expect(qc.getQueryData(key)).toEqual(legacy); - }); - - it('treats an undefined context as a no-op rollback (defensive against missing context)', () => { - const qc = makeQueryClient(); - qc.setQueryData(key, fullRow({ agentAttention: true })); - expect(() => { - rollbackAgentPushOptimistic({ queryClient: qc, queryKey: key, context: undefined }); - }).not.toThrow(); - // The cache is left intact when no context is provided. - expect(qc.getQueryData(key)).toEqual(fullRow({ agentAttention: true })); - }); -}); - -describe('per-category flip flow (each category in turn)', () => { - const scenarios: { category: NotificationCategoryKey; next: boolean }[] = [ - { category: 'chatMessages', next: false }, - { category: 'agentAttention', next: true }, - { category: 'agentUpdates', next: false }, - { category: 'sessionStatus', next: true }, - { category: 'kiloclawActivity', next: false }, - { category: 'balanceAlerts', next: false }, - { category: 'securityFindings', next: true }, - ]; - - for (const { category, next } of scenarios) { - it(`flips only ${category} → ${next} and rolls back cleanly`, async () => { - const qc = makeQueryClient(); - qc.setQueryData(key, fullRow()); - - const context = await applyAgentPushOptimistic({ - queryClient: qc, - queryKey: key, - next, - category, - }); - const after = readRow(qc); - expect(after[category]).toBe(next); - for (const other of NOTIFICATION_CATEGORY_KEYS) { - if (other !== category) { - expect(after[other]).toBe(DEFAULT_NOTIFICATION_PREFERENCE); - } - } - - rollbackAgentPushOptimistic({ queryClient: qc, queryKey: key, context }); - expect(qc.getQueryData(key)).toEqual(fullRow()); - }); - } -}); diff --git a/apps/mobile/src/lib/notifications.ts b/apps/mobile/src/lib/notifications.ts index 19baa04b5a..3ea4cc4607 100644 --- a/apps/mobile/src/lib/notifications.ts +++ b/apps/mobile/src/lib/notifications.ts @@ -6,6 +6,7 @@ import { z } from 'zod'; import { type PushData, pushDataSchema } from '@kilocode/notifications'; +import { setPendingDeepLink } from './deep-link-launch'; import { notificationPathForData } from './notification-path'; const easConfigSchema = z.object({ projectId: z.string().min(1) }); @@ -71,16 +72,6 @@ export function setupNotificationHandler() { }); } -// Pending deep link from a notification tap (cold start or background). -// Consumed by the root nav after auth/navigation is ready. -let pendingNotificationLink: string | null = null; - -export function getPendingNotificationLink(): string | null { - const link = pendingNotificationLink; - pendingNotificationLink = null; - return link; -} - export function setupNotificationResponseHandler() { const subscription = Notifications.addNotificationResponseReceivedListener(response => { const data = parseNotificationData(response.notification.request.content.data); @@ -97,7 +88,7 @@ export function setupNotificationResponseHandler() { try { router.navigate(path as Href); } catch { - pendingNotificationLink = path; + setPendingDeepLink(path, 'notification'); } }); @@ -112,7 +103,7 @@ export function checkInitialNotification(): void { } const data = parseNotificationData(response.notification.request.content.data); if (data) { - pendingNotificationLink = notificationPathForData(data); + setPendingDeepLink(notificationPathForData(data), 'notification'); } Notifications.clearLastNotificationResponse(); } diff --git a/apps/mobile/src/lib/pending-notification-navigation.test.ts b/apps/mobile/src/lib/pending-navigation.test.ts similarity index 50% rename from apps/mobile/src/lib/pending-notification-navigation.test.ts rename to apps/mobile/src/lib/pending-navigation.test.ts index 296eba7623..96fbcfce99 100644 --- a/apps/mobile/src/lib/pending-notification-navigation.test.ts +++ b/apps/mobile/src/lib/pending-navigation.test.ts @@ -1,14 +1,14 @@ import { describe, expect, it } from 'vitest'; -import { resolvePendingNotificationNavigation } from './pending-notification-navigation'; +import { resolvePendingNavigation } from './pending-navigation'; -describe('pending notification navigation', () => { +describe('pending navigation', () => { it('does not navigate without a pending link', () => { - expect(resolvePendingNotificationNavigation(null)).toBeNull(); + expect(resolvePendingNavigation(null)).toBeNull(); }); it('navigates so the target screen keeps a back stack without duplicate history entries', () => { - expect(resolvePendingNotificationNavigation('/chat/sandbox/conversation')).toEqual({ + expect(resolvePendingNavigation('/chat/sandbox/conversation')).toEqual({ href: '/chat/sandbox/conversation', method: 'navigate', }); diff --git a/apps/mobile/src/lib/pending-notification-navigation.ts b/apps/mobile/src/lib/pending-navigation.ts similarity index 69% rename from apps/mobile/src/lib/pending-notification-navigation.ts rename to apps/mobile/src/lib/pending-navigation.ts index 23f4da393c..512f832691 100644 --- a/apps/mobile/src/lib/pending-notification-navigation.ts +++ b/apps/mobile/src/lib/pending-navigation.ts @@ -1,4 +1,4 @@ -type PendingNotificationNavigation = { +type PendingNavigation = { href: string; method: 'navigate'; }; @@ -6,9 +6,7 @@ type PendingNotificationNavigation = { // `navigate` rather than `replace`: replacing the stack root leaves the target // screen with no back stack (no back button, user stranded), while `navigate` // pushes an entry yet still dedupes if the route is already current. -export function resolvePendingNotificationNavigation( - pendingLink: string | null -): PendingNotificationNavigation | null { +export function resolvePendingNavigation(pendingLink: string | null): PendingNavigation | null { if (!pendingLink) { return null; } diff --git a/apps/mobile/src/lib/universal-link-paths.js b/apps/mobile/src/lib/universal-link-paths.js new file mode 100644 index 0000000000..74b465b1db --- /dev/null +++ b/apps/mobile/src/lib/universal-link-paths.js @@ -0,0 +1,19 @@ +/** Android intent-filter pathPatterns for app.kilo.ai, mirroring + * androidPathPatterns() in @kilocode/app-shared/universal-links. + * Plain .js because the Expo config loader cannot consume workspace TS + * (same reason env-keys.js exists). Drift is CI-caught by + * universal-link-paths.test.ts — keep the two in sync there, never silently. + * @type {string[]} */ +export const UNIVERSAL_LINK_PATH_PATTERNS = [ + '/profile', + '/claw', + '/cloud/sessions', + '/security-agent', + '/security-agent/findings', + '/code-reviews', + '/code-reviews/.*', + '/organizations/.*/security-agent', + '/organizations/.*/security-agent/findings', + '/organizations/.*/code-reviews', + '/organizations/.*/code-reviews/.*', +]; diff --git a/apps/mobile/src/lib/universal-link-paths.test.ts b/apps/mobile/src/lib/universal-link-paths.test.ts new file mode 100644 index 0000000000..fb138bba3e --- /dev/null +++ b/apps/mobile/src/lib/universal-link-paths.test.ts @@ -0,0 +1,10 @@ +import { androidPathPatterns } from '@kilocode/app-shared/universal-links'; +import { describe, expect, it } from 'vitest'; + +import { UNIVERSAL_LINK_PATH_PATTERNS } from './universal-link-paths.js'; + +describe('UNIVERSAL_LINK_PATH_PATTERNS', () => { + it('deep-equals androidPathPatterns() from @kilocode/app-shared/universal-links', () => { + expect(UNIVERSAL_LINK_PATH_PATTERNS).toEqual(androidPathPatterns()); + }); +}); diff --git a/apps/web/jest.config.ts b/apps/web/jest.config.ts index 34bd87f9e0..28d282bcc0 100644 --- a/apps/web/jest.config.ts +++ b/apps/web/jest.config.ts @@ -30,6 +30,7 @@ const config: Config = { '^@kilocode/db$': '/../../packages/db/src/index.ts', '^@kilocode/worker-utils/(.*)$': '/../../packages/worker-utils/src/$1', '^@kilocode/worker-utils$': '/../../packages/worker-utils/src/index.ts', + '^@kilocode/app-shared/(.*)$': '/../../packages/app-shared/src/$1', '^(\\.{1,2}/.+)\\.js$': '$1', '^@/(.*)$': '/src/$1', '^server-only$': '/src/tests/setup/__mocks__/server-only.js', diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index 6ef852ff8a..45360b2570 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -131,6 +131,11 @@ const nextConfig = { // Security headers async headers() { return [ + { + // Extensionless AASA file gets no inferred type; Apple requires application/json. + source: '/.well-known/apple-app-site-association', + headers: [{ key: 'Content-Type', value: 'application/json' }], + }, { source: '/api-docs/swagger-ui/:path*', headers: [ diff --git a/apps/web/public/.well-known/apple-app-site-association b/apps/web/public/.well-known/apple-app-site-association new file mode 100644 index 0000000000..9c5c500bff --- /dev/null +++ b/apps/web/public/.well-known/apple-app-site-association @@ -0,0 +1,24 @@ +{ + "applinks": { + "details": [ + { + "appIDs": ["X96D76J65Z.com.kilocode.kiloapp"], + "components": [ + { "/": "/profile" }, + { "/": "/claw" }, + { "/": "/cloud/sessions" }, + { "/": "/security-agent" }, + { "/": "/security-agent/findings" }, + { "/": "/code-reviews" }, + { "/": "/code-reviews/review-md", "exclude": true }, + { "/": "/code-reviews/*" }, + { "/": "/organizations/*/security-agent" }, + { "/": "/organizations/*/security-agent/findings" }, + { "/": "/organizations/*/code-reviews" }, + { "/": "/organizations/*/code-reviews/review-md", "exclude": true }, + { "/": "/organizations/*/code-reviews/*" } + ] + } + ] + } +} diff --git a/apps/web/public/.well-known/assetlinks.json b/apps/web/public/.well-known/assetlinks.json new file mode 100644 index 0000000000..702755a0d8 --- /dev/null +++ b/apps/web/public/.well-known/assetlinks.json @@ -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" + ] + } + } +] diff --git a/apps/web/src/app/(app)/cloud/sessions/layout.tsx b/apps/web/src/app/(app)/cloud/sessions/layout.tsx index 13ea2df6ca..c7358cce8d 100644 --- a/apps/web/src/app/(app)/cloud/sessions/layout.tsx +++ b/apps/web/src/app/(app)/cloud/sessions/layout.tsx @@ -1,4 +1,7 @@ import { getUserFromAuthOrRedirect } from '@/lib/user/server'; +import { smartAppBannerItunes } from '@/lib/smart-app-banner'; + +export const metadata = { itunes: smartAppBannerItunes('/cloud/sessions') }; export default async function CloudSessionsLayout({ children }: { children: React.ReactNode }) { await getUserFromAuthOrRedirect(); diff --git a/apps/web/src/app/(app)/code-reviews/page.tsx b/apps/web/src/app/(app)/code-reviews/page.tsx index fcd9f1974d..5486f95a28 100644 --- a/apps/web/src/app/(app)/code-reviews/page.tsx +++ b/apps/web/src/app/(app)/code-reviews/page.tsx @@ -1,7 +1,10 @@ import { getUserFromAuthOrRedirect } from '@/lib/user/server'; import { isLocalCodeReviewDevelopmentEnabled } from '@/lib/config.server'; +import { smartAppBannerItunes } from '@/lib/smart-app-banner'; import { ReviewAgentPageClient } from './ReviewAgentPageClient'; +export const metadata = { itunes: smartAppBannerItunes('/code-reviews') }; + type ReviewAgentPageProps = { searchParams: Promise<{ success?: string; error?: string; platform?: string }>; }; diff --git a/apps/web/src/app/(app)/profile/page.tsx b/apps/web/src/app/(app)/profile/page.tsx index 7039729f4e..e0b8b48be8 100644 --- a/apps/web/src/app/(app)/profile/page.tsx +++ b/apps/web/src/app/(app)/profile/page.tsx @@ -27,6 +27,9 @@ import { isFeatureFlagEnabled } from '@/lib/posthog-feature-flags'; import { UserProfileCard } from '@/components/profile/UserProfileCard'; import { getContributorChampionProfileBadgeForUser } from '@/lib/contributor-champions/service'; import { AutoRoutingModeCard } from '@/components/auto-routing/AutoRoutingModeCard'; +import { smartAppBannerItunes } from '@/lib/smart-app-banner'; + +export const metadata = { itunes: smartAppBannerItunes('/profile') }; export default async function ProfilePage({ searchParams }: AppPageProps) { const user = await getUserFromAuthOrRedirect('/users/sign_in'); diff --git a/apps/web/src/app/(app)/security-agent/findings/page.tsx b/apps/web/src/app/(app)/security-agent/findings/page.tsx index 3c9192b60d..f2378b37d1 100644 --- a/apps/web/src/app/(app)/security-agent/findings/page.tsx +++ b/apps/web/src/app/(app)/security-agent/findings/page.tsx @@ -1,6 +1,9 @@ import { SecurityFindingsPage } from '@/components/security-agent/SecurityFindingsPage'; +import { smartAppBannerItunes } from '@/lib/smart-app-banner'; import { Suspense } from 'react'; +export const metadata = { itunes: smartAppBannerItunes('/security-agent/findings') }; + export default function FindingsPage() { return ( { + const raw = readFileSync(join(wellKnownDir, 'apple-app-site-association'), 'utf8'); + const parsed = JSON.parse(raw) as { + applinks: { + details: Array<{ + appIDs: string[]; + components: ReturnType; + }>; + }; + webcredentials?: unknown; + }; + + it('parses as JSON with a single applinks.details entry', () => { + expect(parsed.applinks.details).toHaveLength(1); + }); + + it('targets the Kilo iOS app ID', () => { + expect(parsed.applinks.details[0]?.appIDs).toEqual(['X96D76J65Z.com.kilocode.kiloapp']); + }); + + it('components match aasaComponents() from app-shared', () => { + expect(parsed.applinks.details[0]?.components).toEqual(aasaComponents()); + }); + + it('does not declare webcredentials', () => { + expect(parsed).not.toHaveProperty('webcredentials'); + }); +}); + +describe('assetlinks.json', () => { + const raw = readFileSync(join(wellKnownDir, 'assetlinks.json'), 'utf8'); + const parsed = JSON.parse(raw) as Array<{ + relation: string[]; + target: { + namespace: string; + package_name: string; + sha256_cert_fingerprints: string[]; + }; + }>; + + it('is a single-entry Digital Asset Links array', () => { + expect(parsed).toHaveLength(1); + }); + + it('delegates handle_all_urls to the Android app package', () => { + const entry = parsed[0]; + expect(entry?.relation).toContain('delegate_permission/common.handle_all_urls'); + expect(entry?.target.namespace).toBe('android_app'); + expect(entry?.target.package_name).toBe('com.kilocode.kiloapp'); + }); + + it('lists only the verified EAS upload-certificate fingerprint', () => { + expect(parsed[0]?.target.sha256_cert_fingerprints).toEqual([ANDROID_UPLOAD_CERT_FINGERPRINT]); + }); +}); diff --git a/apps/web/src/lib/smart-app-banner.test.ts b/apps/web/src/lib/smart-app-banner.test.ts new file mode 100644 index 0000000000..7954080ef4 --- /dev/null +++ b/apps/web/src/lib/smart-app-banner.test.ts @@ -0,0 +1,43 @@ +import { APP_URL } from '@/lib/constants'; +import { UNIVERSAL_LINK_ROUTES } from '@kilocode/app-shared/universal-links'; +import { smartAppBannerItunes } from '@/lib/smart-app-banner'; + +describe('smartAppBannerItunes', () => { + it('returns appId only when called with no path', () => { + const result = smartAppBannerItunes(); + expect(result).toEqual({ appId: '6761193135' }); + expect(result).not.toHaveProperty('appArgument'); + }); + + it.each(['/profile', '/code-reviews', '/security-agent/findings', '/cloud/sessions'] as const)( + 'sets appArgument for surface path %s', + path => { + expect(smartAppBannerItunes(path)).toEqual({ + appId: '6761193135', + appArgument: `${APP_URL}${path}`, + }); + } + ); + + it('accepts every literal UNIVERSAL_LINK_ROUTES webPath', () => { + const literalPaths = UNIVERSAL_LINK_ROUTES.map(route => route.webPath).filter( + webPath => !webPath.includes('*') + ); + + for (const path of literalPaths) { + expect(smartAppBannerItunes(path)).toEqual({ + appId: '6761193135', + appArgument: `${APP_URL}${path}`, + }); + } + }); + + it.each(['/admin', '/s/sess_1', '/code-reviews/review-md', '/login'] as const)( + 'throws for unmapped path %s', + path => { + expect(() => smartAppBannerItunes(path)).toThrow( + `smartAppBannerItunes: path "${path}" is not deep-linkable` + ); + } + ); +}); diff --git a/apps/web/src/lib/smart-app-banner.ts b/apps/web/src/lib/smart-app-banner.ts new file mode 100644 index 0000000000..da43b7faf5 --- /dev/null +++ b/apps/web/src/lib/smart-app-banner.ts @@ -0,0 +1,32 @@ +import { APP_URL } from '@/lib/constants'; +import { webPathToAppPath } from '@kilocode/app-shared/universal-links'; + +const IOS_APP_STORE_ID = '6761193135'; + +/** + * Apple Smart App Banner `itunes` metadata. + * + * Call with no path for the site-wide banner (appId only). Call with a web path + * to set `app-argument` so OPEN deep-links into the matching app screen. + * + * Throws when `path` is not deep-linkable: metadata is evaluated at build time, + * so an unmapped path fails the build instead of shipping an OPEN button that + * dumps users on the home screen. + */ +export function smartAppBannerItunes(): { appId: string }; +export function smartAppBannerItunes(path: string): { appId: string; appArgument: string }; +export function smartAppBannerItunes( + path?: string +): { appId: string } | { appId: string; appArgument: string } { + if (path === undefined) { + return { appId: IOS_APP_STORE_ID }; + } + + if (webPathToAppPath(path) === null) { + throw new Error( + `smartAppBannerItunes: path "${path}" is not deep-linkable (webPathToAppPath returned null)` + ); + } + + return { appId: IOS_APP_STORE_ID, appArgument: `${APP_URL}${path}` }; +} diff --git a/packages/app-shared/package.json b/packages/app-shared/package.json index 7182025696..996d9c238f 100644 --- a/packages/app-shared/package.json +++ b/packages/app-shared/package.json @@ -9,7 +9,8 @@ "./security-agent": "./src/security-agent/index.ts", "./code-review": "./src/code-review/index.ts", "./organizations": "./src/organizations/index.ts", - "./platforms": "./src/platforms.ts" + "./platforms": "./src/platforms.ts", + "./universal-links": "./src/universal-links/index.ts" }, "scripts": { "typecheck": "tsgo --noEmit", diff --git a/packages/app-shared/src/universal-links/index.ts b/packages/app-shared/src/universal-links/index.ts new file mode 100644 index 0000000000..c273810f23 --- /dev/null +++ b/packages/app-shared/src/universal-links/index.ts @@ -0,0 +1,10 @@ +export { + UNIVERSAL_LINK_ROUTES, + parseKiloWebPath, + webPathToAppPath, + resolveIncomingUrl, + aasaComponents, + androidPathPatterns, +} from './routes'; + +export type { UniversalLinkRoute, AasaComponent } from './routes'; diff --git a/packages/app-shared/src/universal-links/routes.test.ts b/packages/app-shared/src/universal-links/routes.test.ts new file mode 100644 index 0000000000..6bedfa9a3c --- /dev/null +++ b/packages/app-shared/src/universal-links/routes.test.ts @@ -0,0 +1,321 @@ +import { describe, expect, it } from 'vitest'; + +import { + UNIVERSAL_LINK_ROUTES, + aasaComponents, + androidPathPatterns, + parseKiloWebPath, + resolveIncomingUrl, + webPathToAppPath, +} from './routes'; + +const WEB = 'https://app.kilo.ai'; + +/** Expected targets for the 11 table rows (concrete ids where wildcards). */ +const ROW_CASES = [ + { + path: '/profile', + app: '/(app)/(tabs)/(3_profile)', + }, + { + path: '/claw', + app: '/(app)/(tabs)/(1_kiloclaw)', + }, + { + path: '/cloud/sessions', + app: '/(app)/(tabs)/(2_agents)', + }, + { + path: '/security-agent', + app: '/(app)/(tabs)/(3_profile)/security-agent/personal', + }, + { + path: '/security-agent/findings', + app: '/(app)/(tabs)/(3_profile)/security-agent/personal/findings', + }, + { + path: '/code-reviews', + app: '/(app)/(tabs)/(3_profile)/code-reviewer/personal', + }, + { + path: '/code-reviews/rev_9', + app: '/(app)/(tabs)/(3_profile)/code-reviewer/personal/reviews/rev_9', + }, + { + path: '/organizations/org_123/security-agent', + app: '/(app)/(tabs)/(3_profile)/security-agent/org_123', + }, + { + path: '/organizations/org_123/security-agent/findings', + app: '/(app)/(tabs)/(3_profile)/security-agent/org_123/findings', + }, + { + path: '/organizations/org_123/code-reviews', + app: '/(app)/(tabs)/(3_profile)/code-reviewer/org_123', + }, + { + path: '/organizations/org_123/code-reviews/rev_9', + app: '/(app)/(tabs)/(3_profile)/code-reviewer/org_123/reviews/rev_9', + }, +] as const; + +describe('UNIVERSAL_LINK_ROUTES', () => { + it('has exactly 11 rows', () => { + expect(UNIVERSAL_LINK_ROUTES).toHaveLength(11); + }); +}); + +describe('resolveIncomingUrl — https rows', () => { + it.each(ROW_CASES)('maps $path', ({ path, app }) => { + expect(resolveIncomingUrl(`${WEB}${path}`)).toBe(app); + }); +}); + +describe('resolveIncomingUrl — kiloapp:// forms', () => { + it.each(ROW_CASES)('maps kiloapp:///$path (empty host)', ({ path, app }) => { + expect(resolveIncomingUrl(`kiloapp://${path}`)).toBe(app); + }); + + it('maps kiloapp://profile (host carries first segment)', () => { + expect(resolveIncomingUrl('kiloapp://profile')).toBe('/(app)/(tabs)/(3_profile)'); + }); + + it('maps kiloapp://app.kilo.ai/profile', () => { + expect(resolveIncomingUrl('kiloapp://app.kilo.ai/profile')).toBe('/(app)/(tabs)/(3_profile)'); + }); + + it('maps kiloapp://code-reviews/rev_9 (host + path as pathname)', () => { + expect(resolveIncomingUrl('kiloapp://code-reviews/rev_9')).toBe( + '/(app)/(tabs)/(3_profile)/code-reviewer/personal/reviews/rev_9' + ); + }); + + it('maps kiloapp:///cloud/sessions', () => { + expect(resolveIncomingUrl('kiloapp:///cloud/sessions')).toBe('/(app)/(tabs)/(2_agents)'); + }); +}); + +describe('wildcard capture substitution', () => { + it('row 7: /code-reviews/rev_9', () => { + expect(resolveIncomingUrl(`${WEB}/code-reviews/rev_9`)).toBe( + '/(app)/(tabs)/(3_profile)/code-reviewer/personal/reviews/rev_9' + ); + }); + + it('row 8: /organizations/org_123/security-agent', () => { + expect(resolveIncomingUrl(`${WEB}/organizations/org_123/security-agent`)).toBe( + '/(app)/(tabs)/(3_profile)/security-agent/org_123' + ); + }); + + it('row 9: /organizations/org_123/security-agent/findings', () => { + expect(resolveIncomingUrl(`${WEB}/organizations/org_123/security-agent/findings`)).toBe( + '/(app)/(tabs)/(3_profile)/security-agent/org_123/findings' + ); + }); + + it('row 10: /organizations/org_123/code-reviews', () => { + expect(resolveIncomingUrl(`${WEB}/organizations/org_123/code-reviews`)).toBe( + '/(app)/(tabs)/(3_profile)/code-reviewer/org_123' + ); + }); + + it('row 11: /organizations/org_123/code-reviews/rev_9', () => { + expect(resolveIncomingUrl(`${WEB}/organizations/org_123/code-reviews/rev_9`)).toBe( + '/(app)/(tabs)/(3_profile)/code-reviewer/org_123/reviews/rev_9' + ); + }); + + it('inserts captures literally — dollar substitution patterns stay verbatim', () => { + // String replacement would run ECMA-262 GetSubstitution on the capture: + // `$&` re-inserts the placeholder, `$'` splices the un-substituted tail. + expect(resolveIncomingUrl(`${WEB}/code-reviews/a$&b`)).toBe( + '/(app)/(tabs)/(3_profile)/code-reviewer/personal/reviews/a$&b' + ); + expect(resolveIncomingUrl(`${WEB}/organizations/a$'b/code-reviews/rev_9`)).toBe( + "/(app)/(tabs)/(3_profile)/code-reviewer/a$'b/reviews/rev_9" + ); + expect(resolveIncomingUrl(`${WEB}/organizations/a$b/code-reviews/rev_9`)).toBe( + '/(app)/(tabs)/(3_profile)/code-reviewer/a$b/reviews/rev_9' + ); + }); + + it('never rescans inserted captures — a literal in a capture stays verbatim', () => { + // Per-capture replaceAll passes would substitute the `<2>` inserted for + // `<1>` again in pass 2. Single-pass substitution must not. + expect(resolveIncomingUrl('kiloapp:///organizations/<2>/code-reviews/rev_9')).toBe( + '/(app)/(tabs)/(3_profile)/code-reviewer/<2>/reviews/rev_9' + ); + expect(resolveIncomingUrl('kiloapp:///organizations/org_1/code-reviews/<1>')).toBe( + '/(app)/(tabs)/(3_profile)/code-reviewer/org_1/reviews/<1>' + ); + }); +}); + +describe('exclusions', () => { + it('excludes /code-reviews/review-md via resolveIncomingUrl', () => { + expect(resolveIncomingUrl(`${WEB}/code-reviews/review-md`)).toBeNull(); + }); + + it('excludes /organizations/o1/code-reviews/review-md via resolveIncomingUrl', () => { + expect(resolveIncomingUrl(`${WEB}/organizations/o1/code-reviews/review-md`)).toBeNull(); + }); + + it('excludes via webPathToAppPath', () => { + expect(webPathToAppPath('/code-reviews/review-md')).toBeNull(); + expect(webPathToAppPath('/organizations/o1/code-reviews/review-md')).toBeNull(); + }); +}); + +describe('wildcard is single-segment', () => { + it('rejects /code-reviews/a/b', () => { + expect(resolveIncomingUrl(`${WEB}/code-reviews/a/b`)).toBeNull(); + expect(webPathToAppPath('/code-reviews/a/b')).toBeNull(); + }); + + it('rejects /organizations/a/b/security-agent', () => { + expect(resolveIncomingUrl(`${WEB}/organizations/a/b/security-agent`)).toBeNull(); + expect(webPathToAppPath('/organizations/a/b/security-agent')).toBeNull(); + }); +}); + +describe('trailing slash normalisation', () => { + it('maps /profile/ to row 1', () => { + expect(resolveIncomingUrl(`${WEB}/profile/`)).toBe('/(app)/(tabs)/(3_profile)'); + expect(parseKiloWebPath(`${WEB}/profile/`)).toBe('/profile'); + }); + + it('maps /cloud/sessions/ to row 3', () => { + expect(resolveIncomingUrl(`${WEB}/cloud/sessions/`)).toBe('/(app)/(tabs)/(2_agents)'); + expect(parseKiloWebPath(`${WEB}/cloud/sessions/`)).toBe('/cloud/sessions'); + }); + + it('does not strip more than one trailing slash: /profile// → null', () => { + // Strip once → "/profile/", which matches nothing. + expect(parseKiloWebPath(`${WEB}/profile//`)).toBe('/profile/'); + expect(webPathToAppPath('/profile/')).toBeNull(); + expect(resolveIncomingUrl(`${WEB}/profile//`)).toBeNull(); + }); +}); + +describe('deliberately unmapped paths', () => { + const unmapped = [ + '/users/sign_in', + '/auth/verify-magic-link', + '/device-auth', + '/sign-in-to-editor', + '/openclaw-advisor', + '/account-verification', + '/github-app', + '/collab', + '/payments/xyz', + '/admin/users', + '/privacy-app', + '/terms-app', + '/vscode-marketplace', + '/claw/chat/conv_1', + '/s/sess_1', + '/share/sh_1', + '/', + '/login', + ] as const; + + it.each(unmapped)('%s → null', path => { + expect(resolveIncomingUrl(`${WEB}${path}`)).toBeNull(); + expect(webPathToAppPath(path)).toBeNull(); + }); +}); + +describe('foreign / garbage input', () => { + const garbage = [ + 'https://kilo.ai/profile', + 'https://staging-app.kilo.ai/profile', + 'https://evil.example.com/profile', + 'ftp://app.kilo.ai/profile', + 'not a url', + '', + 'kiloapp://', + ] as const; + + it.each(garbage)('%j → null, no throw', raw => { + expect(() => parseKiloWebPath(raw)).not.toThrow(); + expect(parseKiloWebPath(raw)).toBeNull(); + expect(() => resolveIncomingUrl(raw)).not.toThrow(); + expect(resolveIncomingUrl(raw)).toBeNull(); + }); +}); + +describe('parseKiloWebPath', () => { + it('is case-insensitive for https host', () => { + expect(parseKiloWebPath('https://APP.KILO.AI/profile')).toBe('/profile'); + }); + + it('drops query strings and fragments', () => { + expect(parseKiloWebPath(`${WEB}/profile?foo=1#bar`)).toBe('/profile'); + expect(parseKiloWebPath(`${WEB}/code-reviews/rev_9?x=1`)).toBe('/code-reviews/rev_9'); + }); + + it('leaves percent-encoding as-is', () => { + expect(parseKiloWebPath(`${WEB}/code-reviews/rev%2F9`)).toBe('/code-reviews/rev%2F9'); + }); + + it('rejects www.kilo.ai and other hosts', () => { + expect(parseKiloWebPath('https://www.kilo.ai/profile')).toBeNull(); + }); +}); + +describe('aasaComponents', () => { + it('returns 13 entries (11 rows + 2 exclusions)', () => { + expect(aasaComponents()).toHaveLength(13); + }); + + it('every entry has a "/" key', () => { + for (const entry of aasaComponents()) { + expect(entry).toHaveProperty('/'); + expect(typeof entry['/']).toBe('string'); + } + }); + + it('emits exclusion immediately before row 7 (/code-reviews/*)', () => { + const components = aasaComponents(); + // Rows 1–6 are exact (indices 0–5). Row 7 exclusion then row 7 → indices 6–7. + expect(components[6]).toEqual({ '/': '/code-reviews/review-md', exclude: true }); + expect(components[7]).toEqual({ '/': '/code-reviews/*' }); + }); + + it('emits exclusion immediately before row 11 (/organizations/*/code-reviews/*)', () => { + const components = aasaComponents(); + // After row 7 pair: rows 8–10 (3 exact) → indices 8,9,10. + // Row 11 exclusion + row 11 → indices 11–12. + expect(components[11]).toEqual({ + '/': '/organizations/*/code-reviews/review-md', + exclude: true, + }); + expect(components[12]).toEqual({ '/': '/organizations/*/code-reviews/*' }); + }); + + it('keeps table * verbatim (Apple glob crosses /)', () => { + const paths = aasaComponents().map(c => c['/']); + expect(paths).toContain('/code-reviews/*'); + expect(paths).toContain('/organizations/*/code-reviews/*'); + expect(paths).toContain('/organizations/*/security-agent'); + }); +}); + +describe('androidPathPatterns', () => { + it('deep-equals the expected 11-string list', () => { + expect(androidPathPatterns()).toEqual([ + '/profile', + '/claw', + '/cloud/sessions', + '/security-agent', + '/security-agent/findings', + '/code-reviews', + '/code-reviews/.*', + '/organizations/.*/security-agent', + '/organizations/.*/security-agent/findings', + '/organizations/.*/code-reviews', + '/organizations/.*/code-reviews/.*', + ]); + }); +}); diff --git a/packages/app-shared/src/universal-links/routes.ts b/packages/app-shared/src/universal-links/routes.ts new file mode 100644 index 0000000000..7c76167c41 --- /dev/null +++ b/packages/app-shared/src/universal-links/routes.ts @@ -0,0 +1,351 @@ +/** + * Single source of truth for Kilo web path → mobile app group-href mapping. + * Consumed by AASA, assetlinks.json, Android intentFilters, and the runtime + * URL translator — one table, four consumers. + * + * Pattern syntax: literal segments plus `*`, where `*` matches exactly ONE + * path segment ([^/]+) and is captured for substitution into the target as + * `` (nth capture, 1-based). No regex. + */ + +export type UniversalLinkRoute = { + /** Web path pattern on app.kilo.ai (literal segments + `*`). */ + readonly webPath: string; + /** Expo Router group href target; `` = nth wildcard capture. */ + readonly appPath: string; + /** + * Segment values that must NOT match this row's wildcard position(s). + * For multi-wildcard rows the exclusion applies to the final-segment + * wildcard (the rightmost `*`). + */ + readonly exclusions?: readonly string[]; +}; + +export const UNIVERSAL_LINK_ROUTES: readonly UniversalLinkRoute[] = [ + { webPath: '/profile', appPath: '/(app)/(tabs)/(3_profile)' }, + { webPath: '/claw', appPath: '/(app)/(tabs)/(1_kiloclaw)' }, + { webPath: '/cloud/sessions', appPath: '/(app)/(tabs)/(2_agents)' }, + { + webPath: '/security-agent', + appPath: '/(app)/(tabs)/(3_profile)/security-agent/personal', + }, + { + webPath: '/security-agent/findings', + appPath: '/(app)/(tabs)/(3_profile)/security-agent/personal/findings', + }, + { + webPath: '/code-reviews', + appPath: '/(app)/(tabs)/(3_profile)/code-reviewer/personal', + }, + { + webPath: '/code-reviews/*', + appPath: '/(app)/(tabs)/(3_profile)/code-reviewer/personal/reviews/<1>', + exclusions: ['review-md'], + }, + { + webPath: '/organizations/*/security-agent', + appPath: '/(app)/(tabs)/(3_profile)/security-agent/<1>', + }, + { + webPath: '/organizations/*/security-agent/findings', + appPath: '/(app)/(tabs)/(3_profile)/security-agent/<1>/findings', + }, + { + webPath: '/organizations/*/code-reviews', + appPath: '/(app)/(tabs)/(3_profile)/code-reviewer/<1>', + }, + { + webPath: '/organizations/*/code-reviews/*', + appPath: '/(app)/(tabs)/(3_profile)/code-reviewer/<1>/reviews/<2>', + exclusions: ['review-md'], + }, +] as const; + +const KILO_WEB_HOST = 'app.kilo.ai'; + +/** + * Full URL → normalised web pathname. Host/scheme guard lives here only. + * Returns `null` for anything that is not ours. Never throws. + * + * Prefer plain string parsing — Hermes does not guarantee full WHATWG URL. + */ +export function parseKiloWebPath(raw: string): string | null { + if (typeof raw !== 'string' || raw.length === 0) { + return null; + } + + const trimmed = raw.trim(); + if (trimmed.length === 0) { + return null; + } + + const schemeEnd = trimmed.indexOf('://'); + if (schemeEnd <= 0) { + return null; + } + + const scheme = trimmed.slice(0, schemeEnd).toLowerCase(); + const afterScheme = trimmed.slice(schemeEnd + 3); + + // Drop query and fragment before path work. + const withoutQuery = stripQueryAndFragment(afterScheme); + + if (scheme === 'https' || scheme === 'http') { + return parseHttpsPath(withoutQuery); + } + + if (scheme === 'kiloapp') { + return parseKiloappPath(withoutQuery); + } + + return null; +} + +function stripQueryAndFragment(value: string): string { + let end = value.length; + const q = value.indexOf('?'); + const h = value.indexOf('#'); + if (q >= 0 && q < end) end = q; + if (h >= 0 && h < end) end = h; + return value.slice(0, end); +} + +function parseHttpsPath(authorityAndPath: string): string | null { + // authorityAndPath is "host[:port]/path..." + if (authorityAndPath.length === 0) { + return null; + } + + const slash = authorityAndPath.indexOf('/'); + const authority = slash < 0 ? authorityAndPath : authorityAndPath.slice(0, slash); + const pathPart = slash < 0 ? '' : authorityAndPath.slice(slash); + + // Strip optional port for host comparison. + const colon = authority.indexOf(':'); + const host = (colon >= 0 ? authority.slice(0, colon) : authority).toLowerCase(); + + if (host !== KILO_WEB_HOST) { + return null; + } + + return normalisePathname(pathPart.length === 0 ? '/' : pathPart); +} + +/** + * kiloapp:// forms (OS delivery varies): + * - kiloapp:///profile (empty host) → /profile + * - kiloapp://profile (host = first segment) → /profile + * - kiloapp://app.kilo.ai/profile → /profile + * - kiloapp://code-reviews/abc (non-app host) → /code-reviews/abc + */ +function parseKiloappPath(authorityAndPath: string): string | null { + // Empty everything: "kiloapp://" → authorityAndPath === "" + if (authorityAndPath.length === 0) { + return null; + } + + // Triple-slash form leaves a leading "/" (empty host): "/profile" or "/" + if (authorityAndPath.startsWith('/')) { + return normalisePathname(authorityAndPath); + } + + const slash = authorityAndPath.indexOf('/'); + const hostPart = slash < 0 ? authorityAndPath : authorityAndPath.slice(0, slash); + const pathPart = slash < 0 ? '' : authorityAndPath.slice(slash); + + if (hostPart.length === 0) { + return normalisePathname(pathPart.length === 0 ? '/' : pathPart); + } + + // Host is app.kilo.ai (case-insensitive) → path alone is the pathname. + if (hostPart.toLowerCase() === KILO_WEB_HOST) { + return normalisePathname(pathPart.length === 0 ? '/' : pathPart); + } + + // Non-empty host that is not app.kilo.ai: treat host + path as the path. + const combined = `/${hostPart}${pathPart}`; + return normalisePathname(combined); +} + +/** Strip exactly one trailing `/` (except bare `/`). */ +function normalisePathname(pathname: string): string { + if (pathname.length > 1 && pathname.endsWith('/')) { + return pathname.slice(0, -1); + } + return pathname; +} + +/** + * Pure table lookup on an already-normalised pathname. + * `null` = unmapped or excluded. + */ +export function webPathToAppPath(webPath: string): string | null { + for (const route of UNIVERSAL_LINK_ROUTES) { + const captures = matchPattern(route.webPath, webPath); + if (captures === null) { + continue; + } + + if (route.exclusions && route.exclusions.length > 0) { + // Exclusion applies to the final-segment wildcard (rightmost capture). + const finalCapture = captures[captures.length - 1]; + if (finalCapture !== undefined && route.exclusions.includes(finalCapture)) { + return null; + } + } + + return substituteCaptures(route.appPath, captures); + } + + return null; +} + +/** + * Match `pattern` against `path`. Both are absolute pathnames. + * `*` matches exactly one non-empty segment. Returns captures or null. + */ +function matchPattern(pattern: string, path: string): string[] | null { + const patternSegments = splitSegments(pattern); + const pathSegments = splitSegments(path); + + if (patternSegments.length !== pathSegments.length) { + return null; + } + + const captures: string[] = []; + + for (const [i, pSeg] of patternSegments.entries()) { + const pathSeg = pathSegments[i]; + if (pathSeg === undefined) { + // Unreachable: segment counts are checked equal above. + return null; + } + + if (pSeg === '*') { + // Single non-empty segment; empty segments never appear after split. + if (pathSeg.length === 0) { + return null; + } + captures.push(pathSeg); + continue; + } + + if (pSeg !== pathSeg) { + return null; + } + } + + return captures; +} + +function splitSegments(pathname: string): string[] { + // "/a/b" → ["a","b"]; "/" → []; "" → [] + if (pathname === '/' || pathname === '') { + return []; + } + const stripped = pathname.startsWith('/') ? pathname.slice(1) : pathname; + // Do not collapse empty segments from double slashes — leave them so + // "/profile//" (after one trailing-slash strip → "/profile/") fails to match. + return stripped.split('/'); +} + +/** + * Single pass over the template: already-inserted capture text is never + * rescanned, so a captured segment — external input — always lands verbatim. + * A per-capture `replaceAll` loop would both run ECMA-262 GetSubstitution on + * `$` patterns in the capture and re-replace a literal `` inserted by an + * earlier pass. + */ +function substituteCaptures(appPath: string, captures: string[]): string { + const parts: string[] = []; + let cursor = 0; + + while (cursor < appPath.length) { + const open = appPath.indexOf('<', cursor); + if (open < 0) { + break; + } + const close = appPath.indexOf('>', open); + if (close < 0) { + break; + } + + const token = appPath.slice(open + 1, close); + const n = Number.parseInt(token, 10); + const capture = String(n) === token && n >= 1 ? captures[n - 1] : undefined; + if (capture === undefined) { + // Not a known placeholder — keep the text verbatim and move past it. + parts.push(appPath.slice(cursor, close + 1)); + cursor = close + 1; + continue; + } + + parts.push(appPath.slice(cursor, open), capture); + cursor = close + 1; + } + + parts.push(appPath.slice(cursor)); + return parts.join(''); +} + +/** Single entry point: raw URL → Expo Router group href (or null). */ +export function resolveIncomingUrl(raw: string): string | null { + const webPath = parseKiloWebPath(raw); + if (webPath === null) { + return null; + } + return webPathToAppPath(webPath); +} + +export type AasaComponent = { + '/': string; + exclude?: boolean; +}; + +/** + * Compile the table to Apple AASA `components` entries. + * Exclusion components are emitted immediately BEFORE their row. + * Apple AASA `*` is a glob that crosses `/`; that superset is deliberate — + * the table's `*` is kept verbatim in AASA output. + */ +export function aasaComponents(): AasaComponent[] { + const components: AasaComponent[] = []; + + for (const route of UNIVERSAL_LINK_ROUTES) { + if (route.exclusions) { + for (const exclusion of route.exclusions) { + components.push({ + '/': patternWithFinalWildcardReplaced(route.webPath, exclusion), + exclude: true, + }); + } + } + components.push({ '/': route.webPath }); + } + + return components; +} + +/** Replace the rightmost `*` in a pattern with a concrete segment value. */ +function patternWithFinalWildcardReplaced(pattern: string, value: string): string { + const idx = pattern.lastIndexOf('*'); + if (idx < 0) { + return pattern; + } + return pattern.slice(0, idx) + value + pattern.slice(idx + 1); +} + +/** + * Compile the table to Android `pathPattern` strings. + * Each `*` segment becomes `.*`. Android cannot express exclusions — the + * runtime matcher (`webPathToAppPath` / `resolveIncomingUrl`) is the + * exclusion enforcement. + */ +export function androidPathPatterns(): string[] { + return UNIVERSAL_LINK_ROUTES.map(route => + route.webPath + .split('/') + .map(seg => (seg === '*' ? '.*' : seg)) + .join('/') + ); +}