Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions kilo-app/app.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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: [
[
Expand Down
4 changes: 4 additions & 0 deletions kilo-app/src/app/(app)/_layout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { Stack } from 'expo-router';

import { useDeepLink } from '@/lib/hooks/use-deep-link';

export default function AppLayout() {
useDeepLink();

return (
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="(tabs)" />
Expand Down
21 changes: 21 additions & 0 deletions kilo-app/src/app/+native-intent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { resolveDeepLink } from '@/lib/deep-link';

/**
* Intercept incoming native URLs (universal links, custom scheme)
* and rewrite them to internal app routes before Expo Router processes them.
*
* @see https://docs.expo.dev/router/advanced/native-intent/
*/
export function redirectSystemPath({
path,
initial: _initial,
}: {
path: string;
initial: boolean;
}): string {
try {
return resolveDeepLink(path);
} catch {
return path;
}
}
117 changes: 117 additions & 0 deletions kilo-app/src/lib/deep-link.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { type Href } from 'expo-router';

export type PendingDeepLink = {
targetRoute: Href;
organizationId?: string;
};

let pending: PendingDeepLink | null = null;
let listener: ((link: PendingDeepLink) => void) | null = null;

function setPendingDeepLink(link: PendingDeepLink) {
if (listener) {
// Warm start: deliver directly, don't store (avoids replay on remount)
listener(link);
} else {
// Cold start: stash for consumePendingDeepLink() on first mount
pending = link;
}
}

export function consumePendingDeepLink(): PendingDeepLink | null {
const link = pending;
pending = null;
return link;
}

/** Subscribe to deep links arriving while the app is open. */
export function onPendingDeepLink(handler: (link: PendingDeepLink) => void): () => void {
listener = handler;
return () => {
if (listener === handler) {
listener = null;
}
};
}

/**
* Normalise an incoming URL (full or path-only) to a bare pathname.
* Returns `null` when the URL doesn't belong to our domain.
*/
function toPathname(raw: string): string | null {
// Already a relative path
if (raw.startsWith('/')) {
return raw;
}

try {
const url = new URL(raw);
const host = url.hostname.replace(/^www\./, '');
if (host !== 'app.kilo.ai') {
return null;
}
return url.pathname;
} catch {
return null;
}
}

const ORG_CLAW_RE = /^\/organizations\/([^/]+)\/claw\/?$/;
const ORG_RE = /^\/organizations\/([^/]+)\/?$/;

const INSTANCE_LIST: Href = '/(app)/(tabs)/(1_kiloclaw)/' as Href;
const PROFILE: Href = '/(app)/profile' as Href;

/**
* Map an incoming web URL / path to an internal app route.
*
* Every recognised URL is stored as a pending deep link so the app can
* navigate there after auth completes (if the user isn't logged in).
* The rewritten path is also returned for direct navigation when the
* user is already authenticated.
*
* - Non-org routes: stored + direct rewrite returned.
* - Org-scoped routes: stored (with orgId for context switch),
* returns `/(app)` so the app layout picks it up.
* - Unrecognised paths: returned as-is (Expo Router default handling).
*/
export function resolveDeepLink(raw: string): string {
const pathname = toPathname(raw);
if (pathname == null) {
return raw;
}

// /claw → instance list (personal context)
if (pathname === '/claw' || pathname === '/claw/') {
setPendingDeepLink({ targetRoute: INSTANCE_LIST });
return INSTANCE_LIST as string;
}

// /profile → profile screen
if (pathname === '/profile' || pathname === '/profile/') {
setPendingDeepLink({ targetRoute: PROFILE });
return PROFILE as string;
}

// /organizations/[orgId]/claw → switch to org context + instance list
const orgClawMatch = ORG_CLAW_RE.exec(pathname);
if (orgClawMatch?.[1]) {
setPendingDeepLink({
targetRoute: INSTANCE_LIST,
organizationId: orgClawMatch[1],
});
return '/(app)';
}

// /organizations/[orgId] → switch to org context + profile
const orgMatch = ORG_RE.exec(pathname);
if (orgMatch?.[1]) {
setPendingDeepLink({
targetRoute: PROFILE,
organizationId: orgMatch[1],
});
return '/(app)';
}

return raw;
}
57 changes: 57 additions & 0 deletions kilo-app/src/lib/hooks/use-deep-link.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { useRouter } from 'expo-router';
import { useCallback, useEffect } from 'react';
import { toast } from 'sonner-native';

import { useAppContext } from '@/lib/context/context-context';
import { consumePendingDeepLink, onPendingDeepLink, type PendingDeepLink } from '@/lib/deep-link';
import { trpcClient } from '@/lib/trpc';

/**
* Consumes pending deep links (set by +native-intent), validates access
* for org-scoped links, switches context if needed, and navigates.
*
* Handles both cold-start (pending link set before mount — covers the
* case where the user wasn't logged in and went through auth first) and
* warm-start (new link arriving while the app is already open).
*
* Must be called inside the (app) layout (inside providers, after auth).
*/
export function useDeepLink() {
const { setContext } = useAppContext();
const router = useRouter();

const handleLink = useCallback(
(link: PendingDeepLink) => {
const navigate = async () => {
try {
if (link.organizationId) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Personal deep links never reset org context

link.organizationId is the only branch that calls setContext, so /claw and /profile now reuse whatever organization context was already active. If someone is currently scoped to an org and opens https://app.kilo.ai/claw or https://app.kilo.ai/profile, this code navigates to the screen but leaves that org selected, so they never reach the personal page those URLs represent.

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]);
}
51 changes: 51 additions & 0 deletions public/.well-known/apple-app-site-association
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
{
"applinks": {
"details": [
{
"appIDs": ["X96D76J65Z.com.kilocode.kiloapp"],
"components": [
{
"/": "/claw",
"comment": "KiloClaw instance list"
},
{
"/": "/claw/",
"comment": "KiloClaw instance list (trailing slash)"
},
{
"/": "/profile",
"comment": "User profile"
},
{
"/": "/profile/",
"comment": "User profile (trailing slash)"
},
{
"/": "/organizations/*/claw",
"comment": "Org-scoped KiloClaw instance list"
},
{
"/": "/organizations/*/claw/",
"comment": "Org-scoped KiloClaw instance list (trailing slash)"
},
{
"/": "/organizations/*/*",
"exclude": true,
"comment": "Exclude all other org sub-pages from universal links"
},
{
"/": "/organizations/*",
"comment": "Organization profile"
},
{
"/": "/organizations/*/",
"comment": "Organization profile (trailing slash)"
}
]
}
]
},
"webcredentials": {
"apps": ["X96D76J65Z.com.kilocode.kiloapp"]
}
}
12 changes: 12 additions & 0 deletions public/.well-known/assetlinks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.kilocode.kiloapp",
"sha256_cert_fingerprints": [
"39:87:0D:39:0E:45:88:4F:B8:B0:2D:A5:0C:E4:97:9B:EC:67:B2:CF:5F:69:D9:A8:84:79:5E:65:FD:B8:85:E7"
]
}
}
]
Loading