Skip to content
Merged
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
2 changes: 2 additions & 0 deletions apps/mobile/src/components/app-root-providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { type ReactNode } from 'react';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { Toaster } from 'sonner-native';

import { DevSessionInjector } from '@/components/dev-session-injector';
import { OfflineBanner } from '@/components/offline-banner';
import { AuthProvider } from '@/lib/auth/auth-context';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
Expand All @@ -23,6 +24,7 @@ export function AppRootProviders({ children }: { readonly children: ReactNode })
<QueryClientProvider client={queryClient}>
<QueryClientNativeLifecycle />
<AuthProvider>
{__DEV__ ? <DevSessionInjector /> : null}
<OrganizationProvider>
<ActionSheetProvider>
<>
Expand Down
26 changes: 26 additions & 0 deletions apps/mobile/src/components/dev-session-injector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { useEffect } from 'react';

import { useAuth } from '@/lib/auth/auth-context';
import { consumePendingDevSession, subscribePendingDevSession } from '@/lib/dev-session-inject';

export function DevSessionInjector() {
const { signIn } = useAuth();

useEffect(() => {
const apply = (): void => {
const credentials = consumePendingDevSession();
if (!credentials) {
return;
}
void signIn(credentials.token, credentials.refreshToken, credentials.expiresIn);
};

apply();
const unsubscribe = subscribePendingDevSession(apply);
return () => {
unsubscribe();
};
}, [signIn]);

return null;
}
32 changes: 32 additions & 0 deletions apps/mobile/src/lib/deep-link-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
captureLaunchDeepLink,
getPendingDeepLink,
} from './deep-link-launch';
import { _resetDevSessionInjectForTests, consumePendingDevSession } from './dev-session-inject';
import { setGitHubInstallReturnOutcome } from './github-install-return';

const mocks = vi.hoisted(() => ({
Expand Down Expand Up @@ -36,10 +37,22 @@ vi.mock('@kilocode/app-shared/universal-links', async importOriginal => {
});

const MAPPED_CASES = [
{
path: 'https://app.kilo.ai/home',
href: '/(app)/(tabs)/(0_home)',
},
{
path: 'https://app.kilo.ai/profile',
href: '/(app)/(tabs)/(3_profile)',
},
{
path: 'https://app.kilo.ai/profile/preferences',
href: '/(app)/(tabs)/(3_profile)/preferences',
},
{
path: 'https://app.kilo.ai/cloud/sessions/ses_1',
href: '/(app)/agent-chat/ses_1',
},
{
path: 'https://app.kilo.ai/security-agent/findings',
href: '/(app)/(tabs)/(3_profile)/security-agent/personal/findings',
Expand All @@ -53,15 +66,19 @@ const MAPPED_CASES = [
describe('redirectSystemPath', () => {
beforeEach(() => {
_resetDeepLinkLaunchForTests();
_resetDevSessionInjectForTests();
setGitHubInstallReturnOutcome(null);
mocks.navigate.mockReset();
mocks.shouldThrow = false;
vi.stubGlobal('__DEV__', true);
});

afterEach(() => {
_resetDeepLinkLaunchForTests();
_resetDevSessionInjectForTests();
setGitHubInstallReturnOutcome(null);
mocks.shouldThrow = false;
vi.unstubAllGlobals();
});

describe('cold invariant', () => {
Expand Down Expand Up @@ -104,6 +121,21 @@ describe('redirectSystemPath', () => {
});
});

describe('dev session inject', () => {
it('stashes credentials from a kiloapp URL in a dev build', () => {
const path =
'kiloapp:///home?dev_session_token=tok&dev_session_refresh=ref&dev_session_expires_in=3600';
const result = redirectSystemPath({ path, initial: true });
expect(result).toBeNull();
expect(getPendingDeepLink()).toBe('/(app)/(tabs)/(0_home)');
expect(consumePendingDevSession()).toEqual({
token: 'tok',
refreshToken: 'ref',
expiresIn: 3600,
});
});
});

describe('kiloapp:// forms', () => {
it('cold kiloapp:///profile stashes group href and returns null', () => {
const result = redirectSystemPath({ path: 'kiloapp:///profile', initial: true });
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/lib/deep-link-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { type Href, router } from 'expo-router';
import { resolveIncomingUrl } from '@kilocode/app-shared/universal-links';

import { setPendingDeepLink, wasLaunchLinkHandled } from './deep-link-launch';
import { takeDevSessionFromUrl } from './dev-session-inject';
import { parseGitHubReturnParams, setGitHubInstallReturnOutcome } from './github-install-return';

/** Target app path for the /cloud/sessions universal-link route. */
Expand Down Expand Up @@ -45,6 +46,7 @@ export function redirectSystemPath({
initial: boolean;
}): string | null {
try {
takeDevSessionFromUrl(path);
const href = resolveIncomingUrl(path);
// Untouched → default handling (and future share intent).
if (href == null) {
Expand Down
4 changes: 3 additions & 1 deletion apps/mobile/src/lib/deep-link-launch.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import {
_resetDeepLinkLaunchForTests,
Expand All @@ -11,10 +11,12 @@ import {
describe('deep-link-launch', () => {
beforeEach(() => {
_resetDeepLinkLaunchForTests();
vi.stubGlobal('__DEV__', true);
});

afterEach(() => {
_resetDeepLinkLaunchForTests();
vi.unstubAllGlobals();
});

describe('pending slot', () => {
Expand Down
3 changes: 3 additions & 0 deletions apps/mobile/src/lib/deep-link-launch.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { resolveIncomingUrl } from '@kilocode/app-shared/universal-links';

import { takeDevSessionFromUrl } from './dev-session-inject';

type DeepLinkSource = 'universal-link' | 'notification';

type GetLinkingURL = () => string | null;
Expand Down Expand Up @@ -74,6 +76,7 @@ export function captureLaunchDeepLink(): void {
if (!url) {
return;
}
takeDevSessionFromUrl(url);
const href = resolveIncomingUrl(url);
if (href) {
setPendingDeepLink(href, 'universal-link');
Expand Down
59 changes: 59 additions & 0 deletions apps/mobile/src/lib/dev-session-inject.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import {
_resetDevSessionInjectForTests,
consumePendingDevSession,
parseDevSessionQuery,
subscribePendingDevSession,
takeDevSessionFromUrl,
} from './dev-session-inject';

const URL =
'kiloapp:///home?dev_session_token=tok&dev_session_refresh=ref&dev_session_expires_in=3600';

describe('dev-session-inject', () => {
beforeEach(() => {
_resetDevSessionInjectForTests();
vi.stubGlobal('__DEV__', true);
});

afterEach(() => {
_resetDevSessionInjectForTests();
vi.unstubAllGlobals();
});

it('parses a complete query in a dev build', () => {
expect(parseDevSessionQuery(URL)).toEqual({
token: 'tok',
refreshToken: 'ref',
expiresIn: 3600,
});
});

it('ignores an incomplete query', () => {
expect(parseDevSessionQuery('kiloapp:///home?dev_session_token=tok')).toBeNull();
});

it('is a no-op outside a dev build', () => {
vi.stubGlobal('__DEV__', false);
expect(parseDevSessionQuery(URL)).toBeNull();
takeDevSessionFromUrl(URL);
expect(consumePendingDevSession()).toBeNull();
});

it('notifies a subscriber and is single-shot', () => {
const listener = vi.fn();
const unsubscribe = subscribePendingDevSession(() => {
listener();
});
takeDevSessionFromUrl(URL);
expect(listener).toHaveBeenCalledOnce();
expect(consumePendingDevSession()).toEqual({
token: 'tok',
refreshToken: 'ref',
expiresIn: 3600,
});
expect(consumePendingDevSession()).toBeNull();
unsubscribe();
});
});
71 changes: 71 additions & 0 deletions apps/mobile/src/lib/dev-session-inject.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
type DevSessionCredentials = {
token: string;
refreshToken: string;
expiresIn: number;
};

const TOKEN_PARAM = 'dev_session_token';
const REFRESH_PARAM = 'dev_session_refresh';
const EXPIRES_PARAM = 'dev_session_expires_in';

let pending: DevSessionCredentials | null = null;
const listeners = new Set<() => void>();

export function parseDevSessionQuery(raw: string): DevSessionCredentials | null {
if (!__DEV__) {
return null;
}
if (raw.length === 0) {
return null;
}
const queryStart = raw.indexOf('?');
if (queryStart === -1) {
return null;
}
let queryEnd = raw.length;
const hash = raw.indexOf('#', queryStart);
if (hash !== -1) {
queryEnd = hash;
}
const params = new URLSearchParams(raw.slice(queryStart + 1, queryEnd));
const token = params.get(TOKEN_PARAM);
const refreshToken = params.get(REFRESH_PARAM);
const expiresInRaw = params.get(EXPIRES_PARAM);
if (!token || !refreshToken || !expiresInRaw) {
return null;
}
const expiresIn = Number(expiresInRaw);
if (!Number.isFinite(expiresIn) || expiresIn <= 0) {
return null;
}
return { token, refreshToken, expiresIn };
}

export function takeDevSessionFromUrl(raw: string): void {
const credentials = parseDevSessionQuery(raw);
if (!credentials) {
return;
}
pending = credentials;
for (const listener of listeners) {
listener();
}
}

export function consumePendingDevSession(): DevSessionCredentials | null {
const credentials = pending;
pending = null;
return credentials;
}

export function subscribePendingDevSession(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}

export function _resetDevSessionInjectForTests(): void {
pending = null;
listeners.clear();
}
3 changes: 3 additions & 0 deletions apps/mobile/src/lib/universal-link-paths.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@
* universal-link-paths.test.ts — keep the two in sync there, never silently.
* @type {string[]} */
export const UNIVERSAL_LINK_PATH_PATTERNS = [
'/home',
'/profile',
'/profile/preferences',
'/claw',
'/cloud/sessions',
'/cloud/sessions/.*',
'/security-agent',
'/security-agent/findings',
'/code-reviews',
Expand Down
3 changes: 3 additions & 0 deletions apps/web/public/.well-known/apple-app-site-association
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@
{
"appIDs": ["X96D76J65Z.com.kilocode.kiloapp"],
"components": [
{ "/": "/home" },
{ "/": "/profile" },
{ "/": "/profile/preferences" },
{ "/": "/claw" },
{ "/": "/cloud/sessions" },
{ "/": "/cloud/sessions/*" },
{ "/": "/security-agent" },
{ "/": "/security-agent/findings" },
{ "/": "/code-reviews" },
Expand Down
Loading