From fcfdafb8cb89494930ab1574425008f252a88533 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 24 Sep 2026 16:52:39 +0000 Subject: [PATCH 1/2] fix(mobile): render repository picker and avoid dev-host sign-in alert Surface: mobile-app Explorer finding: 3 visual defects on ios: On the New session screen the 'Repositor; The Repository section shows its heading; The system sign-in consent dialog asks t The user-agent explorer found these 3 while using the app like a user. They are one kind of defect on one platform, so they are ONE item: the pull request must fix and prove every one of them. One must-run scenario per finding: the plan needs 3 musts, one for each finding below. The explorer never edits product code. Evidence (from the device runs): --- Finding 1 of 3: new-task: On the New session screen the 'Repository' section header sits above a blank void with no selector or empty-state text, while the 'Start session' button is the only control below it. Flow: new-task Found on revision: f11be7907e Repro: 1. set this state first: credits 30; reviews 3; seed app:api-token e2e-mobile-cloud-android@example.com; org; seed app:github-integration a7e4d40b-c28c-4df1-9a1e-f88e7eb467f1 --installation-id=144771093 --repository=iscekic/backtester 2. open the app on 2E687210-E477-4D23-94F1-9736C860ACD8 3. reach new-task 4. the capture shows the defect named below Observed: On the New session screen the 'Repository' section header sits above a blank void with no selector or empty-state text, while the 'Start session' button is the only control below it. Expected: the screen renders without this defect - ~/.local/share/kwf/findings/explorer-3-visual-defects-on-ios-on-the-new-session-scree-4948f538 --- .../new-session-repository-section.test.ts | 27 +++++++++++++ .../agents/new-session-repository-section.tsx | 40 +++++++++++-------- .../new-session-repository-state.test.ts | 21 +++++++++- .../agents/new-session-repository-state.ts | 12 +++++- .../src/lib/auth/use-device-auth.test.ts | 33 ++++++++++++++- apps/mobile/src/lib/auth/use-device-auth.ts | 27 ++++++++++--- 6 files changed, 134 insertions(+), 26 deletions(-) diff --git a/apps/mobile/src/components/agents/new-session-repository-section.test.ts b/apps/mobile/src/components/agents/new-session-repository-section.test.ts index af2a3c6596..1afe0ea17e 100644 --- a/apps/mobile/src/components/agents/new-session-repository-section.test.ts +++ b/apps/mobile/src/components/agents/new-session-repository-section.test.ts @@ -109,6 +109,33 @@ beforeEach(() => { collapseState.setConnectCtaCollapsed.mockClear(); }); +describe('NewSessionRepositorySection repository picker', () => { + it('renders the picker under the Repository heading when no provider has rows yet', () => { + // Findings 1 and 2: with both providers settled-empty but no rows, the + // picker was gated off (`hasRepos || anyLoading`), leaving only the + // `Repository` heading above the Start session button — a blank void with + // no control under it. The picker trigger reserves its height and already + // covers loading, empty and disabled, so it must always render. + const renderer = mountSection({ + repositories: [], + groups: [group('github', 'repos'), group('gitlab', 'repos')], + }); + + expect(renderer.root.findAllByType('RepoSelector' as never)).toHaveLength(1); + }); + + it('renders the picker in its loading state while every provider is unsettled', () => { + const renderer = mountSection({ + repositories: [], + groups: [group('github', 'loading'), group('gitlab', 'loading')], + }); + + const picker = renderer.root.findAllByType('RepoSelector' as never)[0]; + expect(picker).toBeDefined(); + expect(picker?.props.isLoading).toBe(true); + }); +}); + describe('NewSessionRepositorySection branch row', () => { it.each([ ['github:owner/repo', githubRow], diff --git a/apps/mobile/src/components/agents/new-session-repository-section.tsx b/apps/mobile/src/components/agents/new-session-repository-section.tsx index bebb956c2d..8c7abae712 100644 --- a/apps/mobile/src/components/agents/new-session-repository-section.tsx +++ b/apps/mobile/src/components/agents/new-session-repository-section.tsx @@ -95,11 +95,11 @@ function connectNoteKey(platform: RepositoryPlatform): string | undefined { /** * Provider-aware repository section. One group per provider renders its own - * empty/error state independently, and the picker trigger lists every - * repository plus the Recently used rows when any provider has rows. A - * provider's expanded connect prompt renders only before a repository is - * selected. Afterwards, compact actions keep other providers reachable without - * contradicting the completed selection or requiring it to be cleared. + * empty/error state independently, and the picker trigger always renders, + * listing every repository plus the Recently used rows. A provider's expanded + * connect prompt renders only before a repository is selected. Afterwards, + * compact actions keep other providers reachable without contradicting the + * completed selection or requiring it to be cleared. */ export function NewSessionRepositorySection({ disabled, @@ -135,17 +135,25 @@ export function NewSessionRepositorySection({ {t('common.repository')} - {(hasRepos || anyLoading) && ( - - )} + {/* + The picker renders unconditionally. Its trigger always reserves its + height and already covers loading ("Loading..."), empty ("Select + repository") and disabled states, so gating it off (the old + `hasRepos || anyLoading`) could leave the `Repository` heading as the + section's only child — a blank void above Start session — while a + provider query was paused (`isLoading === false`, no data) or a + connect card was waiting on the persisted-collapse read. The web panel + renders its repository trigger unconditionally too. + */} + {isCloneEntry ? null : ( { ).toBe('loading'); }); + it('returns loading while the provider query has produced no data yet', () => { + // A paused query (iOS boot / NetInfo probe not settled) reports + // `isLoading === false` and leaves `data` undefined, so + // `integrationInstalled` is undefined. That provider's list is not known + // yet and must never read as settled (`'repos'`), or the section renders a + // heading with no control under it. + expect( + resolveProviderStatus({ + isLoading: false, + isError: false, + integrationInstalled: undefined, + repositoryCount: 0, + }) + ).toBe('loading'); + }); + it('returns error when the query failed with no cached repos', () => { expect( resolveProviderStatus({ @@ -39,11 +55,14 @@ describe('resolveProviderStatus', () => { }); it('keeps cached repos visible after a background refetch error', () => { + // Cached rows and the integration flag come from the same query data, so a + // background refetch error that keeps its rows keeps a defined + // `integrationInstalled` too; `undefined` is reserved for "no data yet". expect( resolveProviderStatus({ isLoading: false, isError: true, - integrationInstalled: undefined, + integrationInstalled: true, repositoryCount: 3, }) ).toBe('repos'); diff --git a/apps/mobile/src/components/agents/new-session-repository-state.ts b/apps/mobile/src/components/agents/new-session-repository-state.ts index 91bd1429d7..5a4476d8f4 100644 --- a/apps/mobile/src/components/agents/new-session-repository-state.ts +++ b/apps/mobile/src/components/agents/new-session-repository-state.ts @@ -60,10 +60,18 @@ export function resolveProviderStatus({ if (isError && repositoryCount === 0) { return 'error'; } - if (integrationInstalled === false) { + // `undefined` means the query has produced no data at all, not "not + // installed": a paused query (iOS boot / the NetInfo probe not settled yet) + // reports `isLoading === false` and leaves `data` undefined, so the flag is + // undefined too. A provider whose list is not known yet must never read as + // settled, or the section renders its heading with no control under it. + if (integrationInstalled === undefined) { + return 'loading'; + } + if (!integrationInstalled) { return 'connect'; } - if (integrationInstalled === true && repositoryCount === 0) { + if (repositoryCount === 0) { return 'connected-empty'; } return 'repos'; diff --git a/apps/mobile/src/lib/auth/use-device-auth.test.ts b/apps/mobile/src/lib/auth/use-device-auth.test.ts index 7c24a1296d..881bed4d4b 100644 --- a/apps/mobile/src/lib/auth/use-device-auth.test.ts +++ b/apps/mobile/src/lib/auth/use-device-auth.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- one device-auth suite: the poll-request and token-parse contract cases plus the hook mount/state cases share one module-mock scaffold */ /* eslint-disable import/first -- vi.mock must precede the hook import so the native modules are stubbed */ import * as React from 'react'; import { act, TestRenderer } from '@/test/renderer'; @@ -25,9 +26,10 @@ vi.mock('expo-web-browser', () => ({ openAuthSessionAsync: vi.fn(), })); +// A product WEB_BASE_URL keeps the SSO case on the native auth session path. vi.mock('@/lib/config', () => ({ API_BASE_URL: 'http://localhost:3000', - WEB_BASE_URL: 'http://localhost:3001', + WEB_BASE_URL: 'https://app.kilo.ai', })); vi.mock('@/lib/auth/device-auth-poll', () => ({ @@ -43,7 +45,7 @@ vi.mock('@/lib/auth/pending-external-auth', () => ({ import { useDeviceAuth } from '@/lib/auth/use-device-auth'; import { startDeviceAuthPoll } from '@/lib/auth/device-auth-poll'; import { pendingDeviceAuthState } from '@/lib/auth/device-auth-state'; -import { openAuthSessionAsync } from 'expo-web-browser'; +import { openAuthSessionAsync, openBrowserAsync } from 'expo-web-browser'; import { clearPendingExternalAuth, readPendingExternalAuth, @@ -252,6 +254,19 @@ async function mountSettled(): Promise<{ current: DeviceAuthResult | null }> { return resultRef; } +/** Runs a fresh sign-in whose server response carries `verificationUrl`, so the + * test observes which browser API the auth host selects. */ +async function startSignin(verificationUrl: string): Promise { + vi.mocked(openAuthSessionAsync).mockClear(); + vi.mocked(openBrowserAsync).mockClear(); + vi.mocked(readPendingExternalAuth).mockResolvedValue({ kind: 'none' }); + fetchMock.mockResolvedValue(Response.json({ code: 'UC', verificationUrl })); + const result = requireResult(await mountSettled()); + await act(async () => { + await result.start('signin'); + }); +} + describe('useDeviceAuth hook', () => { beforeEach(() => { vi.mocked(readPendingExternalAuth).mockReset(); @@ -362,4 +377,18 @@ describe('useDeviceAuth hook', () => { expect(url.searchParams.get('callbackPath')).toBe('/device-auth?code=USER-123&app=1'); expect(url.searchParams.has('ssoOrganizationId')).toBe(false); }); + + it('opens a plain browser for a non-product auth host (no consent alert)', async () => { + const url = 'http://127.0.0.1:3000/device-auth?code=UC-1'; + await startSignin(url); + expect(vi.mocked(openBrowserAsync)).toHaveBeenCalledExactlyOnceWith(url); + expect(vi.mocked(openAuthSessionAsync)).not.toHaveBeenCalled(); + }); + + it('keeps the native auth session for a product auth host', async () => { + const url = 'https://app.kilo.ai/device-auth?code=UC-2'; + await startSignin(url); + expect(vi.mocked(openAuthSessionAsync)).toHaveBeenCalledExactlyOnceWith(url); + expect(vi.mocked(openBrowserAsync)).not.toHaveBeenCalled(); + }); }); diff --git a/apps/mobile/src/lib/auth/use-device-auth.ts b/apps/mobile/src/lib/auth/use-device-auth.ts index a4ebc89355..2f736ff32f 100644 --- a/apps/mobile/src/lib/auth/use-device-auth.ts +++ b/apps/mobile/src/lib/auth/use-device-auth.ts @@ -4,6 +4,7 @@ import { AppState, type AppStateStatus, Platform } from 'react-native'; import { i18n } from '@/i18n'; import { API_BASE_URL, WEB_BASE_URL } from '@/lib/config'; +import { PRODUCTION_HOSTS } from '@/lib/url-contract'; import { getDeviceAuth429Message } from '@/lib/auth/poll-response'; import { parseDeviceAuthCodeResponse } from '@/lib/auth/native-auth-contract'; import { buildClientMetadataHeaders } from '@/lib/client-metadata'; @@ -28,12 +29,28 @@ type DeviceAuthResult = DeviceAuthState & { const START_TIMEOUT_MS = 15_000; -// Android has no native auth session; expo-web-browser's polyfill keeps -// module-level state that can get stuck and reject every future call -// (KILO-APP-22). We poll the server for approval instead of relying on a -// redirect, so a plain browser open is all Android needs. +// iOS's ASWebAuthenticationSession raises a consent alert naming the auth URL's +// host ("" Wants to Use "" to Sign In). On a non-product host — a +// dev or preview stack — that alert shows the user a raw developer address +// instead of a domain they can recognize as Kilo, so only open the native auth +// session when the host is a product host. Otherwise present +// SFSafariViewController via openBrowserAsync: it shares Safari's cookies (an +// existing web session still applies) and raises no consent alert. The flow +// polls the server for approval and never consumes the session redirect, so +// nothing else changes. Android keeps openBrowserAsync because +// expo-web-browser's openAuthSessionAsync polyfill can get stuck (KILO-APP-22). +function isProductAuthHost(url: string): boolean { + try { + return PRODUCTION_HOSTS.includes(new URL(url).hostname); + } catch { + // An unparseable URL cannot be recognized as a product host, so avoid the + // consent alert and fall back to the plain browser. + return false; + } +} + async function openAuthBrowser(url: string) { - await (Platform.OS === 'android' + await (Platform.OS === 'android' || !isProductAuthHost(url) ? WebBrowser.openBrowserAsync(url) : WebBrowser.openAuthSessionAsync(url)); } From 0c7b40687ad790d399c26855e43a186f90eac51f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 25 Sep 2026 20:50:08 +0200 Subject: [PATCH 2/2] fix(mobile): dismiss the dev-host browser when sign-in is approved The device-auth flow ends on the poll's approval, not on the page's redirect. Approval always called `dismissAuthSession`, which does nothing to the plain browser that iOS opens for a non-product host, so that page stayed over the approved app. Move the browser choice into one module that names the presentation (`auth-session` or `plain-browser`), open with it, and dismiss with the API that matches the presentation the flow opened. Pass the presentation to the poll. Android behaviour is unchanged. --- apps/mobile/src/lib/auth/auth-browser.ts | 57 ++++++++++++++++++ .../src/lib/auth/device-auth-poll.test.ts | 60 ++++++++++++++++++- apps/mobile/src/lib/auth/device-auth-poll.ts | 15 ++--- .../src/lib/auth/use-device-auth.test.ts | 6 ++ apps/mobile/src/lib/auth/use-device-auth.ts | 36 +++-------- 5 files changed, 136 insertions(+), 38 deletions(-) create mode 100644 apps/mobile/src/lib/auth/auth-browser.ts diff --git a/apps/mobile/src/lib/auth/auth-browser.ts b/apps/mobile/src/lib/auth/auth-browser.ts new file mode 100644 index 0000000000..cd051ba54e --- /dev/null +++ b/apps/mobile/src/lib/auth/auth-browser.ts @@ -0,0 +1,57 @@ +import * as WebBrowser from 'expo-web-browser'; +import { Platform } from 'react-native'; + +import { PRODUCTION_HOSTS } from '@/lib/url-contract'; + +/** + * Which browser API presents the device-auth page. The flow ends on the poll's + * approval rather than on the page's redirect, so the page must be closed from + * here, and only the API that opened it can close it: `dismissAuthSession` does + * nothing to a plain browser. + */ +export type AuthBrowserKind = 'auth-session' | 'plain-browser'; + +// iOS's ASWebAuthenticationSession raises a consent alert naming the auth URL's +// host ("" Wants to Use "" to Sign In). On a non-product host — a +// dev or preview stack — that alert shows the user a raw developer address +// instead of a domain they can recognize as Kilo, so only open the native auth +// session when the host is a product host. Otherwise present +// SFSafariViewController via openBrowserAsync: it shares Safari's cookies (an +// existing web session still applies) and raises no consent alert. The flow +// polls the server for approval and never consumes the session redirect, so +// nothing else changes. Android keeps openBrowserAsync because +// expo-web-browser's openAuthSessionAsync polyfill can get stuck (KILO-APP-22). +function isProductAuthHost(url: string): boolean { + try { + return PRODUCTION_HOSTS.includes(new URL(url).hostname); + } catch { + // An unparseable URL cannot be recognized as a product host, so avoid the + // consent alert and fall back to the plain browser. + return false; + } +} + +export function resolveAuthBrowserKind(url: string): AuthBrowserKind { + return Platform.OS === 'android' || !isProductAuthHost(url) ? 'plain-browser' : 'auth-session'; +} + +export async function openAuthBrowser(url: string): Promise { + await (resolveAuthBrowserKind(url) === 'plain-browser' + ? WebBrowser.openBrowserAsync(url) + : WebBrowser.openAuthSessionAsync(url)); +} + +/** + * Close the page the flow opened, with the API that matches it. Android opened + * a Chrome custom tab, which the user returns from with the back gesture, and + * the flow has never dismissed it from the app. + */ +export function dismissAuthBrowser(kind: AuthBrowserKind): void { + if (kind === 'auth-session') { + WebBrowser.dismissAuthSession(); + return; + } + if (Platform.OS === 'ios') { + void WebBrowser.dismissBrowser(); + } +} diff --git a/apps/mobile/src/lib/auth/device-auth-poll.test.ts b/apps/mobile/src/lib/auth/device-auth-poll.test.ts index 0065675f49..6939f8c3ad 100644 --- a/apps/mobile/src/lib/auth/device-auth-poll.test.ts +++ b/apps/mobile/src/lib/auth/device-auth-poll.test.ts @@ -1,10 +1,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { dismissAuthSession, dismissBrowser } from 'expo-web-browser'; import { startDeviceAuthPoll } from '@/lib/auth/device-auth-poll'; import { type DeviceAuthState } from '@/lib/auth/device-auth-state'; +const platformMock = vi.hoisted(() => ({ os: 'ios' as 'ios' | 'android' })); + vi.mock('react-native', () => ({ - Platform: { OS: 'android' }, + Platform: { + get OS() { + return platformMock.os; + }, + }, })); vi.mock('expo-application', () => ({ @@ -13,6 +20,7 @@ vi.mock('expo-application', () => ({ vi.mock('expo-web-browser', () => ({ dismissAuthSession: vi.fn(), + dismissBrowser: vi.fn(), })); vi.mock('@/lib/config', () => ({ @@ -21,11 +29,17 @@ vi.mock('@/lib/config', () => ({ const fetchMock = vi.fn(); +function approvedResponse() { + return Response.json({ status: 'approved', token: 'access', expiresIn: 3600 }); +} + function pendingResponse() { return Response.json({ status: 'pending' }, { status: 202 }); } -function makePoll(overrides: { startedAt?: number } = {}) { +function makePoll( + overrides: { startedAt?: number; browserKind?: 'auth-session' | 'plain-browser' } = {} +) { const setState = vi.fn<(updater: (prev: DeviceAuthState) => DeviceAuthState) => void>(); const cleanup = vi.fn<() => void>(); const poll = startDeviceAuthPoll({ @@ -34,6 +48,7 @@ function makePoll(overrides: { startedAt?: number } = {}) { signal: new AbortController().signal, setState, cleanup, + browserKind: 'auth-session', ...overrides, }); return { poll, setState, cleanup }; @@ -43,6 +58,8 @@ beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); fetchMock.mockReset(); + vi.mocked(dismissAuthSession).mockClear(); + vi.mocked(dismissBrowser).mockClear(); globalThis.fetch = fetchMock; }); @@ -150,4 +167,43 @@ describe('startDeviceAuthPoll', () => { expect(cleanup).toHaveBeenCalled(); expect(setState).toHaveBeenCalled(); }); + + it('dismisses the plain browser it opened when approval lands', async () => { + // A non-product auth host opens SFSafariViewController, which + // `dismissAuthSession` cannot close: without the matching dismissal the page + // stays over the approved app. + fetchMock.mockResolvedValue(approvedResponse()); + const { setState } = makePoll({ browserKind: 'plain-browser' }); + + await vi.advanceTimersByTimeAsync(3000); + + expect(vi.mocked(dismissBrowser)).toHaveBeenCalledTimes(1); + expect(vi.mocked(dismissAuthSession)).not.toHaveBeenCalled(); + expect(setState).toHaveBeenCalled(); + }); + + it('dismisses the native auth session it opened when approval lands', async () => { + fetchMock.mockResolvedValue(approvedResponse()); + makePoll({ browserKind: 'auth-session' }); + + await vi.advanceTimersByTimeAsync(3000); + + expect(vi.mocked(dismissAuthSession)).toHaveBeenCalledTimes(1); + expect(vi.mocked(dismissBrowser)).not.toHaveBeenCalled(); + }); + + it('leaves the Android custom tab to the user when approval lands', async () => { + platformMock.os = 'android'; + try { + fetchMock.mockResolvedValue(approvedResponse()); + makePoll({ browserKind: 'plain-browser' }); + + await vi.advanceTimersByTimeAsync(3000); + + expect(vi.mocked(dismissBrowser)).not.toHaveBeenCalled(); + expect(vi.mocked(dismissAuthSession)).not.toHaveBeenCalled(); + } finally { + platformMock.os = 'ios'; + } + }); }); diff --git a/apps/mobile/src/lib/auth/device-auth-poll.ts b/apps/mobile/src/lib/auth/device-auth-poll.ts index 81d83f7da7..dcfc045c05 100644 --- a/apps/mobile/src/lib/auth/device-auth-poll.ts +++ b/apps/mobile/src/lib/auth/device-auth-poll.ts @@ -1,8 +1,6 @@ -import { Platform } from 'react-native'; -import * as WebBrowser from 'expo-web-browser'; - import { i18n } from '@/i18n'; import { API_BASE_URL } from '@/lib/config'; +import { type AuthBrowserKind, dismissAuthBrowser } from '@/lib/auth/auth-browser'; import { classifyPollResponse } from '@/lib/auth/poll-response'; import { buildClientMetadataHeaders } from '@/lib/client-metadata'; import { @@ -31,9 +29,14 @@ export function startDeviceAuthPoll(params: { signal: AbortSignal; setState: (updater: (prev: DeviceAuthState) => DeviceAuthState) => void; cleanup: () => void; + /** + * The API that opened the verification page. Approval ends the flow without + * waiting for the page, so the poll closes it with the matching dismissal. + */ + browserKind: AuthBrowserKind; startedAt?: number; }): DeviceAuthPollHandle { - const { code, deviceCode, signal, setState, cleanup } = params; + const { code, deviceCode, signal, setState, cleanup, browserKind } = params; // A resumed transaction reuses the original start clock so its overall // budget does not restart from `Date.now()` and outlive the server code. @@ -94,9 +97,7 @@ export function startDeviceAuthPoll(params: { if (parsed?.status === 'approved') { cleanup(); - if (Platform.OS !== 'android') { - WebBrowser.dismissAuthSession(); - } + dismissAuthBrowser(browserKind); setState(previous => approvedDeviceAuthState({ code, diff --git a/apps/mobile/src/lib/auth/use-device-auth.test.ts b/apps/mobile/src/lib/auth/use-device-auth.test.ts index 881bed4d4b..4d0ae49464 100644 --- a/apps/mobile/src/lib/auth/use-device-auth.test.ts +++ b/apps/mobile/src/lib/auth/use-device-auth.test.ts @@ -300,6 +300,8 @@ describe('useDeviceAuth hook', () => { expect(resultRef.current?.code).toBe('UC-1234'); expect(resultRef.current?.resumed).toBe(true); expect(startDeviceAuthPoll).toHaveBeenCalledTimes(1); + // The resumed poll closes whatever the flow opened for this URL. + expect(vi.mocked(startDeviceAuthPoll).mock.calls[0]?.[0].browserKind).toBe('plain-browser'); }); it('lets a start() that runs during the restore read win without a second poll', async () => { @@ -383,6 +385,9 @@ describe('useDeviceAuth hook', () => { await startSignin(url); expect(vi.mocked(openBrowserAsync)).toHaveBeenCalledExactlyOnceWith(url); expect(vi.mocked(openAuthSessionAsync)).not.toHaveBeenCalled(); + // Approval ends the flow from the poll, so it must know which page to close: + // `dismissAuthSession` does nothing to this one. + expect(vi.mocked(startDeviceAuthPoll).mock.calls[0]?.[0].browserKind).toBe('plain-browser'); }); it('keeps the native auth session for a product auth host', async () => { @@ -390,5 +395,6 @@ describe('useDeviceAuth hook', () => { await startSignin(url); expect(vi.mocked(openAuthSessionAsync)).toHaveBeenCalledExactlyOnceWith(url); expect(vi.mocked(openBrowserAsync)).not.toHaveBeenCalled(); + expect(vi.mocked(startDeviceAuthPoll).mock.calls[0]?.[0].browserKind).toBe('auth-session'); }); }); diff --git a/apps/mobile/src/lib/auth/use-device-auth.ts b/apps/mobile/src/lib/auth/use-device-auth.ts index 2f736ff32f..8e22244107 100644 --- a/apps/mobile/src/lib/auth/use-device-auth.ts +++ b/apps/mobile/src/lib/auth/use-device-auth.ts @@ -1,10 +1,9 @@ -import * as WebBrowser from 'expo-web-browser'; import { useCallback, useEffect, useRef, useState } from 'react'; -import { AppState, type AppStateStatus, Platform } from 'react-native'; +import { AppState, type AppStateStatus } from 'react-native'; import { i18n } from '@/i18n'; import { API_BASE_URL, WEB_BASE_URL } from '@/lib/config'; -import { PRODUCTION_HOSTS } from '@/lib/url-contract'; +import { openAuthBrowser, resolveAuthBrowserKind } from '@/lib/auth/auth-browser'; import { getDeviceAuth429Message } from '@/lib/auth/poll-response'; import { parseDeviceAuthCodeResponse } from '@/lib/auth/native-auth-contract'; import { buildClientMetadataHeaders } from '@/lib/client-metadata'; @@ -29,32 +28,6 @@ type DeviceAuthResult = DeviceAuthState & { const START_TIMEOUT_MS = 15_000; -// iOS's ASWebAuthenticationSession raises a consent alert naming the auth URL's -// host ("" Wants to Use "" to Sign In). On a non-product host — a -// dev or preview stack — that alert shows the user a raw developer address -// instead of a domain they can recognize as Kilo, so only open the native auth -// session when the host is a product host. Otherwise present -// SFSafariViewController via openBrowserAsync: it shares Safari's cookies (an -// existing web session still applies) and raises no consent alert. The flow -// polls the server for approval and never consumes the session redirect, so -// nothing else changes. Android keeps openBrowserAsync because -// expo-web-browser's openAuthSessionAsync polyfill can get stuck (KILO-APP-22). -function isProductAuthHost(url: string): boolean { - try { - return PRODUCTION_HOSTS.includes(new URL(url).hostname); - } catch { - // An unparseable URL cannot be recognized as a product host, so avoid the - // consent alert and fall back to the plain browser. - return false; - } -} - -async function openAuthBrowser(url: string) { - await (Platform.OS === 'android' || !isProductAuthHost(url) - ? WebBrowser.openBrowserAsync(url) - : WebBrowser.openAuthSessionAsync(url)); -} - export function useDeviceAuth(): DeviceAuthResult { const [state, setState] = useState(idleDeviceAuthState()); @@ -140,6 +113,7 @@ export function useDeviceAuth(): DeviceAuthResult { signal: abort.signal, setState, cleanup, + browserKind: resolveAuthBrowserKind(record.verificationUrl), startedAt: record.startedAt, }); } @@ -247,6 +221,10 @@ export function useDeviceAuth(): DeviceAuthResult { signal: abort.signal, setState, cleanup, + // Approval dismisses the page the flow opened, so the poll has to know + // which API opened it: the plain browser on a non-product host (or on + // Android) needs its own dismissal. + browserKind: resolveAuthBrowserKind(browserUrl), }); await openAuthBrowser(browserUrl);