From 1c56bcd49f609f13747bf11a75bc775549278365 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 2 Apr 2026 19:15:14 +0200 Subject: [PATCH 1/6] feat(kilo-app): add deep linking support for existing web URLs Configure universal links (iOS) and Android App Links for app.kilo.ai. Map /claw, /profile, and /organizations/[orgId]/... web URLs to the corresponding mobile app screens via +native-intent URL rewriting. --- kilo-app/app.config.js | 25 +++++++ kilo-app/src/app/(app)/_layout.tsx | 4 ++ kilo-app/src/app/+native-intent.ts | 21 ++++++ kilo-app/src/lib/deep-link.ts | 93 +++++++++++++++++++++++++ kilo-app/src/lib/hooks/use-deep-link.ts | 30 ++++++++ 5 files changed, 173 insertions(+) create mode 100644 kilo-app/src/app/+native-intent.ts create mode 100644 kilo-app/src/lib/deep-link.ts create mode 100644 kilo-app/src/lib/hooks/use-deep-link.ts diff --git a/kilo-app/app.config.js b/kilo-app/app.config.js index 51b47feac..5c7304bd3 100644 --- a/kilo-app/app.config.js +++ b/kilo-app/app.config.js @@ -11,6 +11,7 @@ const config = { ios: { icon: './assets/images/logo.png', bundleIdentifier: 'com.kilocode.kiloapp', + associatedDomains: ['applinks:app.kilo.ai'], infoPlist: { ITSAppUsesNonExemptEncryption: false, NSMicrophoneUsageDescription: @@ -36,6 +37,30 @@ const config = { monochromeImage: './assets/images/android-icon-foreground.png', }, predictiveBackGestureEnabled: false, + intentFilters: [ + { + action: 'VIEW', + autoVerify: true, + data: [ + { + scheme: 'https', + host: 'app.kilo.ai', + pathPrefix: '/claw', + }, + { + scheme: 'https', + host: 'app.kilo.ai', + pathPrefix: '/profile', + }, + { + scheme: 'https', + host: 'app.kilo.ai', + pathPrefix: '/organizations', + }, + ], + category: ['BROWSABLE', 'DEFAULT'], + }, + ], }, plugins: [ [ diff --git a/kilo-app/src/app/(app)/_layout.tsx b/kilo-app/src/app/(app)/_layout.tsx index 8c605a6de..3d751457b 100644 --- a/kilo-app/src/app/(app)/_layout.tsx +++ b/kilo-app/src/app/(app)/_layout.tsx @@ -1,6 +1,10 @@ import { Stack } from 'expo-router'; +import { useDeepLink } from '@/lib/hooks/use-deep-link'; + export default function AppLayout() { + useDeepLink(); + return ( diff --git a/kilo-app/src/app/+native-intent.ts b/kilo-app/src/app/+native-intent.ts new file mode 100644 index 000000000..36c15d1df --- /dev/null +++ b/kilo-app/src/app/+native-intent.ts @@ -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; + } +} diff --git a/kilo-app/src/lib/deep-link.ts b/kilo-app/src/lib/deep-link.ts new file mode 100644 index 000000000..4bee59a53 --- /dev/null +++ b/kilo-app/src/lib/deep-link.ts @@ -0,0 +1,93 @@ +import { type Href } from 'expo-router'; + +type PendingDeepLink = { + targetRoute: Href; + organizationId: string; +}; + +let pending: PendingDeepLink | null = null; + +function setPendingDeepLink(link: PendingDeepLink) { + pending = link; +} + +export function consumePendingDeepLink(): PendingDeepLink | null { + const link = pending; + pending = null; + return link; +} + +/** + * 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. + * + * - Simple routes are returned directly as a rewritten path string. + * - Org-scoped routes store a pending deep link (for context switching) + * and return `/(app)` so the app layout can pick it up. + * - Unrecognised paths are returned as-is (Expo Router's 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/') { + return INSTANCE_LIST as string; + } + + // /profile → profile screen + if (pathname === '/profile' || pathname === '/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; +} diff --git a/kilo-app/src/lib/hooks/use-deep-link.ts b/kilo-app/src/lib/hooks/use-deep-link.ts new file mode 100644 index 000000000..474fe1c53 --- /dev/null +++ b/kilo-app/src/lib/hooks/use-deep-link.ts @@ -0,0 +1,30 @@ +import { useRouter } from 'expo-router'; +import { useEffect } from 'react'; + +import { useAppContext } from '@/lib/context/context-context'; +import { consumePendingDeepLink } from '@/lib/deep-link'; + +/** + * Consumes a pending org-scoped deep link (set by +native-intent), + * switches context to the target organization, and navigates to the + * destination screen. + * + * Must be called inside the (app) layout (inside providers, after auth). + */ +export function useDeepLink() { + const { setContext } = useAppContext(); + const router = useRouter(); + + useEffect(() => { + const link = consumePendingDeepLink(); + if (!link) { + return; + } + + const navigate = async () => { + await setContext({ type: 'organization', organizationId: link.organizationId }); + router.replace(link.targetRoute); + }; + void navigate(); + }, [setContext, router]); +} From 9467096c4cc30a6df2d99e961ccfa2a837440096 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 2 Apr 2026 19:37:15 +0200 Subject: [PATCH 2/6] feat: add AASA and assetlinks.json for universal/app links Host apple-app-site-association and assetlinks.json under public/.well-known/ so iOS and Android can verify app ownership for deep linking from app.kilo.ai URLs. --- public/.well-known/apple-app-site-association | 46 +++++++++++++++++++ public/.well-known/assetlinks.json | 12 +++++ 2 files changed, 58 insertions(+) create mode 100644 public/.well-known/apple-app-site-association create mode 100644 public/.well-known/assetlinks.json diff --git a/public/.well-known/apple-app-site-association b/public/.well-known/apple-app-site-association new file mode 100644 index 000000000..2976d8f0d --- /dev/null +++ b/public/.well-known/apple-app-site-association @@ -0,0 +1,46 @@ +{ + "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/*", + "comment": "Organization profile" + }, + { + "/": "/organizations/*/", + "comment": "Organization profile (trailing slash)" + } + ] + } + ] + }, + "webcredentials": { + "apps": ["X96D76J65Z.com.kilocode.kiloapp"] + } +} diff --git a/public/.well-known/assetlinks.json b/public/.well-known/assetlinks.json new file mode 100644 index 000000000..702755a0d --- /dev/null +++ b/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" + ] + } + } +] From 13417c36ce0f284b18b7885585f08aaefe10a4a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 2 Apr 2026 20:25:56 +0200 Subject: [PATCH 3/6] fix(kilo-app): address deep linking review feedback - Narrow Android intent filters: replace broad /organizations pathPrefix with specific pathPattern for /organizations/*/claw only - AASA: add explicit exclude for /organizations/*/* to prevent claiming unsupported org sub-pages - Make useDeepLink reactive: subscribe to deep link events so org-scoped links arriving while the app is already open are handled correctly --- kilo-app/app.config.js | 7 +++- kilo-app/src/lib/deep-link.ts | 14 +++++++- kilo-app/src/lib/hooks/use-deep-link.ts | 34 +++++++++++++------ public/.well-known/apple-app-site-association | 5 +++ 4 files changed, 47 insertions(+), 13 deletions(-) diff --git a/kilo-app/app.config.js b/kilo-app/app.config.js index 5c7304bd3..94c9a0f51 100644 --- a/kilo-app/app.config.js +++ b/kilo-app/app.config.js @@ -55,7 +55,12 @@ const config = { { scheme: 'https', host: 'app.kilo.ai', - pathPrefix: '/organizations', + pathPattern: '/organizations/.*/claw', + }, + { + scheme: 'https', + host: 'app.kilo.ai', + pathPattern: '/organizations/.*/claw/', }, ], category: ['BROWSABLE', 'DEFAULT'], diff --git a/kilo-app/src/lib/deep-link.ts b/kilo-app/src/lib/deep-link.ts index 4bee59a53..2852d87b7 100644 --- a/kilo-app/src/lib/deep-link.ts +++ b/kilo-app/src/lib/deep-link.ts @@ -1,14 +1,16 @@ import { type Href } from 'expo-router'; -type PendingDeepLink = { +export type PendingDeepLink = { targetRoute: Href; organizationId: string; }; let pending: PendingDeepLink | null = null; +let listener: ((link: PendingDeepLink) => void) | null = null; function setPendingDeepLink(link: PendingDeepLink) { pending = link; + listener?.(link); } export function consumePendingDeepLink(): PendingDeepLink | null { @@ -17,6 +19,16 @@ export function consumePendingDeepLink(): PendingDeepLink | null { return link; } +/** Subscribe to org-scoped 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. diff --git a/kilo-app/src/lib/hooks/use-deep-link.ts b/kilo-app/src/lib/hooks/use-deep-link.ts index 474fe1c53..d8d601b21 100644 --- a/kilo-app/src/lib/hooks/use-deep-link.ts +++ b/kilo-app/src/lib/hooks/use-deep-link.ts @@ -1,30 +1,42 @@ import { useRouter } from 'expo-router'; -import { useEffect } from 'react'; +import { useCallback, useEffect } from 'react'; import { useAppContext } from '@/lib/context/context-context'; -import { consumePendingDeepLink } from '@/lib/deep-link'; +import { consumePendingDeepLink, onPendingDeepLink, type PendingDeepLink } from '@/lib/deep-link'; /** - * Consumes a pending org-scoped deep link (set by +native-intent), + * Consumes pending org-scoped deep links (set by +native-intent), * switches context to the target organization, and navigates to the * destination screen. * + * Handles both cold-start (pending link already set before mount) and + * warm-start (new link arriving while the app is 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 () => { + await setContext({ type: 'organization', organizationId: link.organizationId }); + router.replace(link.targetRoute); + }; + void navigate(); + }, + [setContext, router] + ); + useEffect(() => { + // Cold start: consume any link set before this component mounted const link = consumePendingDeepLink(); - if (!link) { - return; + if (link) { + handleLink(link); } - const navigate = async () => { - await setContext({ type: 'organization', organizationId: link.organizationId }); - router.replace(link.targetRoute); - }; - void navigate(); - }, [setContext, router]); + // Warm start: listen for links arriving while the app is already open + return onPendingDeepLink(handleLink); + }, [handleLink]); } diff --git a/public/.well-known/apple-app-site-association b/public/.well-known/apple-app-site-association index 2976d8f0d..7615b1afe 100644 --- a/public/.well-known/apple-app-site-association +++ b/public/.well-known/apple-app-site-association @@ -28,6 +28,11 @@ "/": "/organizations/*/claw/", "comment": "Org-scoped KiloClaw instance list (trailing slash)" }, + { + "/": "/organizations/*/*", + "exclude": true, + "comment": "Exclude all other org sub-pages from universal links" + }, { "/": "/organizations/*", "comment": "Organization profile" From 1eb3597701db4a60418e7a7f856528152ac9864d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 2 Apr 2026 20:32:20 +0200 Subject: [PATCH 4/6] fix(kilo-app): prevent warm-start deep link replay on remount Only store pending link when no listener is active (cold start). When a listener handles it directly (warm start), skip storage so consumePendingDeepLink() won't replay it on layout remount. --- kilo-app/src/lib/deep-link.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/kilo-app/src/lib/deep-link.ts b/kilo-app/src/lib/deep-link.ts index 2852d87b7..5201dba1f 100644 --- a/kilo-app/src/lib/deep-link.ts +++ b/kilo-app/src/lib/deep-link.ts @@ -9,8 +9,13 @@ let pending: PendingDeepLink | null = null; let listener: ((link: PendingDeepLink) => void) | null = null; function setPendingDeepLink(link: PendingDeepLink) { - pending = link; - listener?.(link); + 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 { From 907e19720cc91238ac8236b45cd18e6c66e436b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 2 Apr 2026 21:07:23 +0200 Subject: [PATCH 5/6] fix(kilo-app): validate org membership before deep link context switch Call organizations.list to verify the user belongs to the target org before switching context. Shows a toast and stays on the current screen if access is denied or the request fails. --- kilo-app/src/lib/hooks/use-deep-link.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/kilo-app/src/lib/hooks/use-deep-link.ts b/kilo-app/src/lib/hooks/use-deep-link.ts index d8d601b21..ad19448ba 100644 --- a/kilo-app/src/lib/hooks/use-deep-link.ts +++ b/kilo-app/src/lib/hooks/use-deep-link.ts @@ -1,12 +1,14 @@ 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 org-scoped deep links (set by +native-intent), - * switches context to the target organization, and navigates to the + * validates org membership, switches context, and navigates to the * destination screen. * * Handles both cold-start (pending link already set before mount) and @@ -21,8 +23,18 @@ export function useDeepLink() { const handleLink = useCallback( (link: PendingDeepLink) => { const navigate = async () => { - await setContext({ type: 'organization', organizationId: link.organizationId }); - router.replace(link.targetRoute); + try { + 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(); }, From 2bae7c464caa9f59b0c6fd2266bd0f10c2dfbbf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 2 Apr 2026 21:14:00 +0200 Subject: [PATCH 6/6] fix(kilo-app): persist deep link target through auth flow Store all recognised deep link targets (not just org-scoped ones) as pending so the app can navigate there after login completes. Previously, non-org links like /claw were rewritten directly but lost if the user wasn't authenticated and got redirected to login first. --- kilo-app/src/lib/deep-link.ts | 19 +++++++++++++------ kilo-app/src/lib/hooks/use-deep-link.ts | 25 ++++++++++++++----------- 2 files changed, 27 insertions(+), 17 deletions(-) diff --git a/kilo-app/src/lib/deep-link.ts b/kilo-app/src/lib/deep-link.ts index 5201dba1f..547104d95 100644 --- a/kilo-app/src/lib/deep-link.ts +++ b/kilo-app/src/lib/deep-link.ts @@ -2,7 +2,7 @@ import { type Href } from 'expo-router'; export type PendingDeepLink = { targetRoute: Href; - organizationId: string; + organizationId?: string; }; let pending: PendingDeepLink | null = null; @@ -24,7 +24,7 @@ export function consumePendingDeepLink(): PendingDeepLink | null { return link; } -/** Subscribe to org-scoped deep links arriving while the app is open. */ +/** Subscribe to deep links arriving while the app is open. */ export function onPendingDeepLink(handler: (link: PendingDeepLink) => void): () => void { listener = handler; return () => { @@ -65,10 +65,15 @@ const PROFILE: Href = '/(app)/profile' as Href; /** * Map an incoming web URL / path to an internal app route. * - * - Simple routes are returned directly as a rewritten path string. - * - Org-scoped routes store a pending deep link (for context switching) - * and return `/(app)` so the app layout can pick it up. - * - Unrecognised paths are returned as-is (Expo Router's default handling). + * 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); @@ -78,11 +83,13 @@ export function resolveDeepLink(raw: string): string { // /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; } diff --git a/kilo-app/src/lib/hooks/use-deep-link.ts b/kilo-app/src/lib/hooks/use-deep-link.ts index ad19448ba..d9b9add01 100644 --- a/kilo-app/src/lib/hooks/use-deep-link.ts +++ b/kilo-app/src/lib/hooks/use-deep-link.ts @@ -7,12 +7,12 @@ import { consumePendingDeepLink, onPendingDeepLink, type PendingDeepLink } from import { trpcClient } from '@/lib/trpc'; /** - * Consumes pending org-scoped deep links (set by +native-intent), - * validates org membership, switches context, and navigates to the - * destination screen. + * 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 already set before mount) and - * warm-start (new link arriving while the app is open). + * 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). */ @@ -24,13 +24,15 @@ export function useDeepLink() { (link: PendingDeepLink) => { const navigate = async () => { try { - 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; + 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 }); } - await setContext({ type: 'organization', organizationId: link.organizationId }); router.replace(link.targetRoute); } catch { toast.error('Failed to open link'); @@ -43,6 +45,7 @@ export function useDeepLink() { useEffect(() => { // 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);