feat: enhance TrustModal with new states and improve pairing flow tests

This commit is contained in:
LiBr
2026-04-13 21:06:22 +08:00
parent c6bf32c426
commit 6f3d8fdd76
4 changed files with 147 additions and 27 deletions

View File

@@ -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(<App />);
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(<App />);

View File

@@ -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<number>(0);
// Modals
const [trustOpen, setTrustOpen] = useState<boolean>(false);
const [trustState, setTrustState] = useState<TrustModalState>(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<PairedDeviceInfo | null> => {
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<PairFlowResult> => {
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}
/>
<TrustModal open={trustOpen} onClose={() => setTrustOpen(false)} pairing={busy.pair} />
<TrustModal
state={trustState}
onClose={() => setTrustState(TRUST_MODAL_CLOSED)}
onRetry={handleTrustRetry}
/>
<TwoFactorModal
open={twoFactor.open}
onSubmit={handleTwoFactorSubmit}

View File

@@ -5,20 +5,31 @@ import { TrustModal } from './TrustModal';
describe('TrustModal', () => {
it('is hidden when closed', () => {
const { container } = render(<TrustModal open={false} onClose={() => {}} pairing={false} />);
const { container } = render(<TrustModal state="closed" onClose={() => {}} onRetry={() => {}} />);
const modal = container.querySelector('.modal');
expect(modal).toHaveAttribute('aria-hidden', 'true');
expect(modal).toBeNull();
});
it('shows pairing state with spinner when pairing', () => {
render(<TrustModal open onClose={() => {}} pairing />);
render(<TrustModal state="pairing" onClose={() => {}} 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(<TrustModal state="pending" onClose={onClose} onRetry={onRetry} />);
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(<TrustModal open onClose={onClose} pairing={false} />);
render(<TrustModal state="paired" onClose={onClose} onRetry={() => {}} />);
expect(screen.getByText('Device Paired')).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Continue' }));
expect(onClose).toHaveBeenCalledTimes(1);

View File

@@ -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 (
<Modal open={open} onClose={onClose} labelledBy="trust-title" closeOnBackdrop={false} closeOnEscape={!pairing}>
<Modal
open
onClose={onClose}
labelledBy="trust-title"
closeOnBackdrop={false}
closeOnEscape={state !== 'pairing'}
>
<div className="flex flex-col items-center text-center">
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-surface" aria-hidden="true">
<svg
@@ -26,7 +37,7 @@ export function TrustModal({ open, onClose, pairing }: TrustModalProps) {
</svg>
</div>
{pairing ? (
{state === 'pairing' ? (
<>
<h2 id="trust-title" className="text-[16px] font-semibold tracking-tight text-ink">
Continue on Your Device
@@ -43,15 +54,35 @@ export function TrustModal({ open, onClose, pairing }: TrustModalProps) {
<span>Waiting for device</span>
</div>
</>
) : state === 'pending' ? (
<>
<h2 id="trust-title" className="text-[16px] font-semibold tracking-tight text-ink">
Trust This Device
</h2>
<p className="mt-2 text-[13px] leading-[1.6] text-muted">
Approve the trust prompt on your iPhone or iPad, then return here to finish pairing.
</p>
<p className="mt-1.5 text-[12px] leading-[1.5] text-subtle">
Tap <strong className="font-semibold text-ink">Trust</strong> and enter your passcode if asked.
</p>
<div className="mt-5 grid w-full gap-2 sm:grid-cols-2">
<Button variant="ghost" onClick={onClose} className="w-full">
Close
</Button>
<Button variant="primary" onClick={onRetry} className="w-full">
I Trusted This Device
</Button>
</div>
</>
) : (
<>
<h2 id="trust-title" className="text-[16px] font-semibold tracking-tight text-ink">
Device Paired
</h2>
<p className="mt-2 text-[13px] leading-[1.6] text-muted">Your device is connected and ready for signing.</p>
<button type="button" onClick={onClose} className="btn btn-primary mt-5 w-full">
<Button variant="primary" onClick={onClose} className="mt-5 w-full">
Continue
</button>
</Button>
</>
)}
</div>