From 6f3d8fdd769aeb58ecba91d0452b61a3071734e7 Mon Sep 17 00:00:00 2001 From: LiBr Date: Mon, 13 Apr 2026 21:06:22 +0800 Subject: [PATCH] feat: enhance TrustModal with new states and improve pairing flow tests --- frontend/src/App.test.tsx | 33 +++++++++ frontend/src/App.tsx | 77 ++++++++++++++++----- frontend/src/components/TrustModal.test.tsx | 19 +++-- frontend/src/components/TrustModal.tsx | 45 ++++++++++-- 4 files changed, 147 insertions(+), 27 deletions(-) diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 7574e7e..6eb008f 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -84,6 +84,10 @@ import { App } from './App'; beforeEach(() => { window.localStorage.clear(); window.location.hash = ''; + Object.defineProperty(navigator, 'usb', { + configurable: true, + value: {}, + }); checkProvisionedMock.mockResolvedValue(false); ensureAnisetteMock.mockResolvedValue({ anisetteData: fakeAnisette, provisioned: true }); loginAccountMock.mockResolvedValue(fakeContext); @@ -115,6 +119,35 @@ describe('App — page + nav', () => { }); }); +describe('App — pairing modal', () => { + it('keeps trust pending separate from paired success and retries cleanly', async () => { + const pendingError = new Error('Pair error=PairingDialogResponsePending'); + pairDeviceFlowMock + .mockImplementationOnce(async (ctx) => { + ctx.onTrustPending(); + throw pendingError; + }) + .mockResolvedValueOnce({ udid: 'UDID-TEST', name: 'iPhone' }); + isPairingDialogPendingErrorMock.mockImplementation( + (error) => error === pendingError || String(error).includes('PairingDialogResponsePending'), + ); + + render(); + await userEvent.click(screen.getByRole('button', { name: /Sign & Install/ })); + await userEvent.click(screen.getByRole('button', { name: 'Connect Device' })); + + expect(await screen.findByText('Trust This Device')).toBeInTheDocument(); + expect(screen.queryByText('Device Paired')).toBeNull(); + + await userEvent.click(screen.getByRole('button', { name: 'I Trusted This Device' })); + expect(await screen.findByText('Device Paired')).toBeInTheDocument(); + await waitFor(() => expect(pairDeviceFlowMock).toHaveBeenCalledTimes(2)); + + await userEvent.click(screen.getByRole('button', { name: 'Continue' })); + expect(screen.queryByText('Device Paired')).toBeNull(); + }); +}); + describe('App — login modal', () => { it('opens login modal on Add Account, then logs in and navigates to sign', async () => { render(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d73c973..8f9a0e0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,7 +7,7 @@ import { Header, type AppPage } from './components/Header'; import { LoginPage } from './components/LoginPage'; import { LoginModal } from './components/LoginModal'; import { SignPage } from './components/SignPage'; -import { TrustModal } from './components/TrustModal'; +import { TrustModal, type TrustModalState } from './components/TrustModal'; import { TwoFactorModal } from './components/TwoFactorModal'; import { ProgressCard } from './components/ProgressCard'; @@ -42,6 +42,7 @@ import { useLog } from './lib/use-log'; const LOGIN_PAGE_HASH = '#/login'; const SIGN_PAGE_HASH = '#/sign'; +const TRUST_MODAL_CLOSED: TrustModalState = 'closed'; type BusyState = { pair: boolean; @@ -50,6 +51,11 @@ type BusyState = { install: boolean; }; +type PairFlowResult = + | { kind: 'paired'; info: PairedDeviceInfo } + | { kind: 'pending' } + | { kind: 'failed' }; + const idleBusy: BusyState = { pair: false, loginSign: false, sign: false, install: false }; function resolvePageFromHash(hash: string): AppPage { @@ -105,7 +111,7 @@ export function App() { const lastInstallPercentRef = useRef(0); // Modals - const [trustOpen, setTrustOpen] = useState(false); + const [trustState, setTrustState] = useState(TRUST_MODAL_CLOSED); const [twoFactor, setTwoFactor] = useState<{ open: boolean; submit: ((code: string) => void) | null; @@ -131,6 +137,7 @@ export function App() { const activeAccountKey = loginContext ? accountKey(loginContext.appleId, loginContext.team.identifier) : null; const cachedAccountKeys = useMemo(() => new Set(accountContextMapRef.current.keys()), [savedAccounts, loginContext]); + const trustOpen = trustState !== TRUST_MODAL_CLOSED; // ---- log + progress plumbing ---- const addLog = useCallback( @@ -259,17 +266,25 @@ export function App() { }, [knownUdids, selectedTargetUdid]); // ---- pair flow ---- - const handlePair = useCallback(async (): Promise => { - if (busyRef.current.pair) return null; + /** + * lockdownd can return PairingDialogResponsePending before the on-device trust sheet is resolved. + * Keep that state separate so the UI never renders "paired" until pairDevice + startSession both finish. + */ + const runPairFlow = useCallback(async (options: { + showSuccess: boolean; + startLogMessage: string; + }): Promise => { + if (busyRef.current.pair) return { kind: 'failed' }; setBusy((prev) => ({ ...prev, pair: true })); - setTrustOpen(true); - addLog('pair: please continue on your device'); + setTrustState('pairing'); + addLog(options.startLogMessage); try { const info = await pairDeviceFlow({ log: addLog, clientRef: directClientRef, onStateChange: () => {}, onTrustPending: () => { + setTrustState('pending'); addLog('pair: waiting for trust confirmation on device'); }, }); @@ -279,20 +294,34 @@ export function App() { setSelectedTargetUdid(info.udid); saveText(SELECTED_DEVICE_UDID_STORAGE_KEY, info.udid); setPairRecordsVersion((v) => v + 1); - setTrustOpen(false); - return info; + setTrustState(options.showSuccess ? 'paired' : TRUST_MODAL_CLOSED); + return { kind: 'paired', info }; } catch (error) { if (isPairingDialogPendingError(error)) { - return null; + return { kind: 'pending' }; } - setTrustOpen(false); + setTrustState(TRUST_MODAL_CLOSED); addLog(`pair failed: ${formatError(error)}`); - return null; + return { kind: 'failed' }; } finally { setBusy((prev) => ({ ...prev, pair: false })); } }, [addLog]); + const handlePair = useCallback(() => { + void runPairFlow({ + showSuccess: true, + startLogMessage: 'pair: please continue on your device', + }); + }, [runPairFlow]); + + const handleTrustRetry = useCallback(() => { + void runPairFlow({ + showSuccess: true, + startLogMessage: 'pair: retrying after trust confirmation', + }); + }, [runPairFlow]); + // ---- login flow ---- const handleLogin = useCallback(async () => { if (busyRef.current.loginSign) return; @@ -433,12 +462,24 @@ export function App() { log: addLog, clientRef: directClientRef, onStateChange: () => {}, - onTrustPending: () => setTrustOpen(true), + onTrustPending: () => setTrustState('pending'), }); let currentDeviceUdid = pairedDeviceInfo?.udid ?? null; if (!client.isSessionStarted) { - const freshInfo = await handlePair(); - currentDeviceUdid = freshInfo?.udid ?? null; + const pairResult = await runPairFlow({ + showSuccess: false, + startLogMessage: 'install: device trust required, continue on your device', + }); + if (pairResult.kind === 'pending') { + addLog('install paused: finish trusting the device, then install again'); + setProgress({ percent: 0, status: 'idle' }); + return; + } + if (pairResult.kind === 'failed') { + setProgress({ percent: 0, status: 'failed' }); + return; + } + currentDeviceUdid = pairResult.info.udid; } if (currentDeviceUdid !== targetUdid) { throw new Error('connected device udid does not match selected target'); @@ -457,7 +498,7 @@ export function App() { } finally { setBusy((prev) => ({ ...prev, install: false })); } - }, [addLog, handlePair, pairedDeviceInfo, prepared, selectedIpaFile, selectedTargetUdid]); + }, [addLog, pairedDeviceInfo, prepared, runPairFlow, selectedIpaFile, selectedTargetUdid]); // ---- switch account ---- const handleSwitchAccount = useCallback( @@ -620,7 +661,11 @@ export function App() { onDismiss={handleDismissProgress} /> - setTrustOpen(false)} pairing={busy.pair} /> + setTrustState(TRUST_MODAL_CLOSED)} + onRetry={handleTrustRetry} + /> { it('is hidden when closed', () => { - const { container } = render( {}} pairing={false} />); + const { container } = render( {}} onRetry={() => {}} />); const modal = container.querySelector('.modal'); - expect(modal).toHaveAttribute('aria-hidden', 'true'); + expect(modal).toBeNull(); }); it('shows pairing state with spinner when pairing', () => { - render( {}} pairing />); + render( {}} onRetry={() => {}} />); expect(screen.getByText('Continue on Your Device')).toBeInTheDocument(); expect(screen.getByText('Waiting for device…')).toBeInTheDocument(); }); + it('shows pending state with retry and close actions', async () => { + const onClose = vi.fn(); + const onRetry = vi.fn(); + render(); + expect(screen.getByText('Trust This Device')).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: 'I Trusted This Device' })); + expect(onRetry).toHaveBeenCalledTimes(1); + await userEvent.click(screen.getByRole('button', { name: 'Close' })); + expect(onClose).toHaveBeenCalledTimes(1); + }); + it('shows success state with Continue button when paired', async () => { const onClose = vi.fn(); - render(); + render( {}} />); expect(screen.getByText('Device Paired')).toBeInTheDocument(); await userEvent.click(screen.getByRole('button', { name: 'Continue' })); expect(onClose).toHaveBeenCalledTimes(1); diff --git a/frontend/src/components/TrustModal.tsx b/frontend/src/components/TrustModal.tsx index 8cfa1a0..df22823 100644 --- a/frontend/src/components/TrustModal.tsx +++ b/frontend/src/components/TrustModal.tsx @@ -1,14 +1,25 @@ +import { Button } from './ui/Button'; import { Modal } from './ui/Modal'; +export type TrustModalState = 'closed' | 'pairing' | 'pending' | 'paired'; + interface TrustModalProps { - open: boolean; + state: TrustModalState; onClose: () => void; - pairing: boolean; + onRetry: () => void; } -export function TrustModal({ open, onClose, pairing }: TrustModalProps) { +export function TrustModal({ state, onClose, onRetry }: TrustModalProps) { + if (state === 'closed') return null; + return ( - +
- {pairing ? ( + {state === 'pairing' ? ( <>

Continue on Your Device @@ -43,15 +54,35 @@ export function TrustModal({ open, onClose, pairing }: TrustModalProps) { Waiting for device…

+ ) : state === 'pending' ? ( + <> +

+ Trust This Device +

+

+ Approve the trust prompt on your iPhone or iPad, then return here to finish pairing. +

+

+ Tap Trust and enter your passcode if asked. +

+
+ + +
+ ) : ( <>

Device Paired

Your device is connected and ready for signing.

- + )}