diff --git a/kilo-app/app.config.js b/kilo-app/app.config.js index 51b47feac..94c9a0f51 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,35 @@ 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', + pathPattern: '/organizations/.*/claw', + }, + { + scheme: 'https', + host: 'app.kilo.ai', + pathPattern: '/organizations/.*/claw/', + }, + ], + 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..547104d95 --- /dev/null +++ b/kilo-app/src/lib/deep-link.ts @@ -0,0 +1,117 @@ +import { type Href } from 'expo-router'; + +export type PendingDeepLink = { + targetRoute: Href; + organizationId?: string; +}; + +let pending: PendingDeepLink | null = null; +let listener: ((link: PendingDeepLink) => void) | null = null; + +function setPendingDeepLink(link: PendingDeepLink) { + if (listener) { + // Warm start: deliver directly, don't store (avoids replay on remount) + listener(link); + } else { + // Cold start: stash for consumePendingDeepLink() on first mount + pending = link; + } +} + +export function consumePendingDeepLink(): PendingDeepLink | null { + const link = pending; + pending = null; + return link; +} + +/** Subscribe to deep links arriving while the app is open. */ +export function onPendingDeepLink(handler: (link: PendingDeepLink) => void): () => void { + listener = handler; + return () => { + if (listener === handler) { + listener = null; + } + }; +} + +/** + * Normalise an incoming URL (full or path-only) to a bare pathname. + * Returns `null` when the URL doesn't belong to our domain. + */ +function toPathname(raw: string): string | null { + // Already a relative path + if (raw.startsWith('/')) { + return raw; + } + + try { + const url = new URL(raw); + const host = url.hostname.replace(/^www\./, ''); + if (host !== 'app.kilo.ai') { + return null; + } + return url.pathname; + } catch { + return null; + } +} + +const ORG_CLAW_RE = /^\/organizations\/([^/]+)\/claw\/?$/; +const ORG_RE = /^\/organizations\/([^/]+)\/?$/; + +const INSTANCE_LIST: Href = '/(app)/(tabs)/(1_kiloclaw)/' as Href; +const PROFILE: Href = '/(app)/profile' as Href; + +/** + * Map an incoming web URL / path to an internal app route. + * + * Every recognised URL is stored as a pending deep link so the app can + * navigate there after auth completes (if the user isn't logged in). + * The rewritten path is also returned for direct navigation when the + * user is already authenticated. + * + * - Non-org routes: stored + direct rewrite returned. + * - Org-scoped routes: stored (with orgId for context switch), + * returns `/(app)` so the app layout picks it up. + * - Unrecognised paths: returned as-is (Expo Router default handling). + */ +export function resolveDeepLink(raw: string): string { + const pathname = toPathname(raw); + if (pathname == null) { + return raw; + } + + // /claw → instance list (personal context) + if (pathname === '/claw' || pathname === '/claw/') { + setPendingDeepLink({ targetRoute: INSTANCE_LIST }); + return INSTANCE_LIST as string; + } + + // /profile → profile screen + if (pathname === '/profile' || pathname === '/profile/') { + setPendingDeepLink({ targetRoute: PROFILE }); + return PROFILE as string; + } + + // /organizations/[orgId]/claw → switch to org context + instance list + const orgClawMatch = ORG_CLAW_RE.exec(pathname); + if (orgClawMatch?.[1]) { + setPendingDeepLink({ + targetRoute: INSTANCE_LIST, + organizationId: orgClawMatch[1], + }); + return '/(app)'; + } + + // /organizations/[orgId] → switch to org context + profile + const orgMatch = ORG_RE.exec(pathname); + if (orgMatch?.[1]) { + setPendingDeepLink({ + targetRoute: PROFILE, + organizationId: orgMatch[1], + }); + return '/(app)'; + } + + return raw; +} 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..d9b9add01 --- /dev/null +++ b/kilo-app/src/lib/hooks/use-deep-link.ts @@ -0,0 +1,57 @@ +import { useRouter } from 'expo-router'; +import { useCallback, useEffect } from 'react'; +import { toast } from 'sonner-native'; + +import { useAppContext } from '@/lib/context/context-context'; +import { consumePendingDeepLink, onPendingDeepLink, type PendingDeepLink } from '@/lib/deep-link'; +import { trpcClient } from '@/lib/trpc'; + +/** + * Consumes pending deep links (set by +native-intent), validates access + * for org-scoped links, switches context if needed, and navigates. + * + * Handles both cold-start (pending link set before mount — covers the + * case where the user wasn't logged in and went through auth first) and + * warm-start (new link arriving while the app is already open). + * + * Must be called inside the (app) layout (inside providers, after auth). + */ +export function useDeepLink() { + const { setContext } = useAppContext(); + const router = useRouter(); + + const handleLink = useCallback( + (link: PendingDeepLink) => { + const navigate = async () => { + try { + if (link.organizationId) { + const orgs = await trpcClient.organizations.list.query(); + const hasAccess = orgs.some(org => org.organizationId === link.organizationId); + if (!hasAccess) { + toast.error('You don\u2019t have access to this organization'); + return; + } + await setContext({ type: 'organization', organizationId: link.organizationId }); + } + router.replace(link.targetRoute); + } catch { + toast.error('Failed to open link'); + } + }; + void navigate(); + }, + [setContext, router] + ); + + useEffect(() => { + // Cold start: consume any link set before this component mounted + // (includes links that waited through the auth flow) + const link = consumePendingDeepLink(); + if (link) { + handleLink(link); + } + + // Warm start: listen for links arriving while the app is already open + return onPendingDeepLink(handleLink); + }, [handleLink]); +} diff --git a/public/.well-known/apple-app-site-association b/public/.well-known/apple-app-site-association new file mode 100644 index 000000000..7615b1afe --- /dev/null +++ b/public/.well-known/apple-app-site-association @@ -0,0 +1,51 @@ +{ + "applinks": { + "details": [ + { + "appIDs": ["X96D76J65Z.com.kilocode.kiloapp"], + "components": [ + { + "/": "/claw", + "comment": "KiloClaw instance list" + }, + { + "/": "/claw/", + "comment": "KiloClaw instance list (trailing slash)" + }, + { + "/": "/profile", + "comment": "User profile" + }, + { + "/": "/profile/", + "comment": "User profile (trailing slash)" + }, + { + "/": "/organizations/*/claw", + "comment": "Org-scoped KiloClaw instance list" + }, + { + "/": "/organizations/*/claw/", + "comment": "Org-scoped KiloClaw instance list (trailing slash)" + }, + { + "/": "/organizations/*/*", + "exclude": true, + "comment": "Exclude all other org sub-pages from universal links" + }, + { + "/": "/organizations/*", + "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" + ] + } + } +]