diff --git a/frontend/src/components/pairing/NearbyPairRequestDialog.tsx b/frontend/src/components/pairing/NearbyPairRequestDialog.tsx index e6d797d4..619f0493 100644 --- a/frontend/src/components/pairing/NearbyPairRequestDialog.tsx +++ b/frontend/src/components/pairing/NearbyPairRequestDialog.tsx @@ -1,12 +1,12 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { useTranslation } from '@/i18n' +import { shortFingerprint } from '@/lib/fingerprint' import { formatDeviceTypeLabel, isKnownPairedEndpoint, - respondNearbyInvite, + respondPairRequest, } from '@/lib/pairing-api' import { IS_PAIRING_CAPABLE } from '@/lib/platform' -import { shortFingerprint } from '@/lib/fingerprint' import { listen } from '@/lib/platform-api' import { pairingDataHydrated, @@ -14,7 +14,6 @@ import { } from '@/store/pairing-data-store' import { AlertDialog, - AlertDialogClose, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, @@ -26,6 +25,7 @@ import { VerificationCode } from './VerificationCode' export type NearbyPairRequestPayload = { remote_endpoint_id: string + session_id: string sender_name: string device_type: string os?: string @@ -38,6 +38,10 @@ export type NearbyPairRequestPayload = { export function NearbyPairRequestDialog() { const { t } = useTranslation() const [request, setRequest] = useState(null) + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + /** Suppresses the controlled dialog close event after a programmatic close. */ + const handledRef = useRef(false) useEffect(() => { if (!IS_PAIRING_CAPABLE) return @@ -64,6 +68,9 @@ export function NearbyPairRequestDialog() { if (isKnownPairedEndpoint(devices, payload.remote_endpoint_id)) { return } + handledRef.current = false + setError(null) + setSubmitting(false) setRequest(payload) })() } @@ -85,25 +92,48 @@ export function NearbyPairRequestDialog() { if (!IS_PAIRING_CAPABLE) return null - const notify = (endpointId: string, accepted: boolean) => { - void respondNearbyInvite(endpointId, accepted).catch(() => { - // Best-effort; UI already proceeded. - }) - } - - const decline = () => { - setRequest((current) => { - if (!current) return current - notify(current.remote_endpoint_id, false) - return null - }) + const decline = async () => { + const current = request + if (!current || submitting) return + handledRef.current = true + setSubmitting(true) + setError(null) + try { + await respondPairRequest( + current.remote_endpoint_id, + current.session_id ?? '', + false + ) + setRequest(null) + } catch (err) { + handledRef.current = false + setError(String(err)) + } finally { + setSubmitting(false) + } } - const accept = () => { + const accept = async () => { const current = request - if (!current) return - setRequest(null) - notify(current.remote_endpoint_id, true) + if (!current || submitting) return + handledRef.current = true + setSubmitting(true) + setError(null) + try { + await respondPairRequest( + current.remote_endpoint_id, + current.session_id ?? '', + true + ) + setRequest(null) + } catch (err) { + // Keep the dialog open so the user can retry; a silent failure here + // used to leave the sender paired and this device not. + handledRef.current = false + setError(String(err)) + } finally { + setSubmitting(false) + } } const typeLabel = request ? formatDeviceTypeLabel(request.device_type) : '' @@ -117,7 +147,7 @@ export function NearbyPairRequestDialog() { { - if (!open) decline() + if (!open && !submitting && !handledRef.current) void decline() }} > @@ -138,6 +168,11 @@ export function NearbyPairRequestDialog() { : ''} + {error ? ( +

+ {t('common:receiver.pairFailed')}: {error} +

+ ) : null}
{/* Guarded on `request`: the popup stays mounted through its close animation, so an unguarded render flashes the "no code" error @@ -155,15 +190,15 @@ export function NearbyPairRequestDialog() { ) : null}
- - {t('common:receiver.declineInvite')} - - } + + diff --git a/frontend/src/components/receiver/PairPromptDialog.tsx b/frontend/src/components/receiver/PairPromptDialog.tsx new file mode 100644 index 00000000..60c9b369 --- /dev/null +++ b/frontend/src/components/receiver/PairPromptDialog.tsx @@ -0,0 +1,103 @@ +import { useEffect, useRef, useState } from 'react' + +import { useTranslation } from '@/i18n' +import { Button } from '../ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '../ui/dialog' +import { useReceiverContext } from './ReceiverProvider' + +/** + * Global post-transfer pairing prompt. + * + * Lives above the routed screens (inside `ReceiverProvider`) so a prompt that + * arrives while the user is on Settings or Devices is not lost. Pairing is + * asynchronous: this dialog only disables itself while the request is being + * delivered; the actual result arrives as `device-paired`. + */ +export function PairPromptDialog() { + const { t } = useTranslation() + const { pairPrompt, acceptPairPrompt, dismissPairPrompt } = + useReceiverContext() + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + /** Suppresses the controlled Dialog's close event after a programmatic close. */ + const handledRef = useRef(false) + + // A new sender (or a retry after dismissal) starts clean. + useEffect(() => { + if (!pairPrompt) return + handledRef.current = false + setError(null) + setSubmitting(false) + }, [pairPrompt]) + + if (!pairPrompt) return null + + const accept = async () => { + // `acceptPairPrompt` clears the prompt on success; mark this as a + // programmatic close so onOpenChange does not record a dismissal. + handledRef.current = true + setSubmitting(true) + setError(null) + try { + // The hook keeps the prompt open when the request cannot be + // delivered, so the user can retry. + const sent = await acceptPairPrompt() + if (!sent) { + handledRef.current = false + setError(t('common:receiver.pairFailed')) + } + } finally { + setSubmitting(false) + } + } + + const dismiss = () => { + handledRef.current = true + dismissPairPrompt() + } + + return ( + { + if (!open && !submitting && !handledRef.current) dismiss() + }} + > + + + {t('common:receiver.pairPromptTitle')} + + {t('common:receiver.pairPromptDescription', { + name: pairPrompt.displayName, + })} + + + {error ? ( +

+ {error} +

+ ) : null} + + + + +
+
+ ) +} diff --git a/frontend/src/components/receiver/Receiver.tsx b/frontend/src/components/receiver/Receiver.tsx index 14a869de..1d61078a 100644 --- a/frontend/src/components/receiver/Receiver.tsx +++ b/frontend/src/components/receiver/Receiver.tsx @@ -1,9 +1,10 @@ -import { Info } from 'lucide-react' +import { ChevronDown, ChevronUp, Info } from 'lucide-react' import { useEffect, useState } from 'react' -import { useReceiverContext } from './ReceiverProvider' import { useTranslation } from '../../i18n/react-i18next-compat' +import { IS_WEB } from '../../lib/platform' import { PulseAnimation } from '../common/PulseAnimation' import { TransferSuccessScreen } from '../common/TransferSuccessScreen' +import { Button } from '../ui/button' import { Dialog, DialogClose, @@ -13,10 +14,18 @@ import { DialogHeader, DialogTitle, } from '../ui/dialog' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '../ui/select' +import { Slider } from '../ui/slider' +import { useReceiverContext } from './ReceiverProvider' +import { ReceiveSaveLocationPicker } from './ReceiveSaveLocationPicker' import { ReceivingActiveCard } from './ReceivingActiveCard' import { TicketInput } from './TicketInput' -import { ReceiveSaveLocationPicker } from './ReceiveSaveLocationPicker' -import { Button } from '../ui/button' interface ReceiverProps { onTransferStateChange: (isReceiving: boolean) => void @@ -39,6 +48,12 @@ export function Receiver({ onTransferStateChange }: ReceiverProps) { isPreviewLoading, isExportPending, fileNames, + splitStrategy, + setSplitStrategy, + maxConcurrency, + setMaxConcurrency, + showAdvancedOptions, + setShowAdvancedOptions, handleTicketChange, handleBrowseFolder, handleReceive, @@ -89,6 +104,93 @@ export function Receiver({ onTransferStateChange }: ReceiverProps) { onTicketChange={handleTicketChange} onReceive={handleReceive} /> + + {/* The web build always downloads sequentially (browser stream + limits), so there is nothing to configure there. */} + {!IS_WEB && ( +
+ + + {showAdvancedOptions && ( +
+
+ + {t('common:receiver.downloadStrategy')} + + +

+ {splitStrategy === 'split' + ? t('common:receiver.strategyParallelDesc') + : t('common:receiver.strategySequentialDesc')} +

+
+ + {splitStrategy === 'split' && ( +
+
+ + {maxConcurrency === null + ? t('common:receiver.maxConcurrencyAuto') + : t('common:receiver.maxConcurrency', { + value: maxConcurrency, + })} + +
+ + setMaxConcurrency( + Array.isArray(vals) ? vals[0] : vals + ) + } + min={2} + max={32} + step={1} + disabled={isReceiving} + /> +

+ {t('common:receiver.maxConcurrencyDesc')} +

+
+ )} +
+ )} +
+ )} ) : isCompleted && transferMetadata ? ( diff --git a/frontend/src/hooks/usePairing.ts b/frontend/src/hooks/usePairing.ts index 72039de8..3e7e432a 100644 --- a/frontend/src/hooks/usePairing.ts +++ b/frontend/src/hooks/usePairing.ts @@ -1,7 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react' -import { listen } from '@/lib/platform-api' -import { IS_PAIRING_CAPABLE } from '@/lib/platform' import { + type DeviceUnpairedPayload, forgetPairedDevice, joinPairing, renamePairedDevice, @@ -9,16 +8,18 @@ import { startPairingHost, stopPairingHost, trustPairedDevice, - type DeviceUnpairedPayload, } from '@/lib/pairing-api' -import { useTranslation } from '../i18n/react-i18next-compat' +import { IS_PAIRING_CAPABLE } from '@/lib/platform' +import { listen } from '@/lib/platform-api' +import { useAppSettingStore } from '@/store/app-setting' +import { usePairingDataStore } from '@/store/pairing-data-store' import { toastManager } from '../components/ui/toast' +import { useTranslation } from '../i18n/react-i18next-compat' import { useNodeCapability } from './useNodeCapability' import { applyPresencePatch, usePairedDeviceEvents, } from './usePairedDeviceEvents' -import { usePairingDataStore } from '@/store/pairing-data-store' const PAIRING_HOST_TTL_SECS = 180 @@ -191,6 +192,8 @@ export function usePairing() { const forget = useCallback( async (endpointId: string) => { await forgetPairedDevice(endpointId) + // Forget means "we may be asked to pair again", not "never prompt". + useAppSettingStore.getState().clearDismissedPairPrompt(endpointId) await hydrate() }, [hydrate] diff --git a/frontend/src/lib/pairing-api.ts b/frontend/src/lib/pairing-api.ts index fa6a3c37..21b3bc04 100644 --- a/frontend/src/lib/pairing-api.ts +++ b/frontend/src/lib/pairing-api.ts @@ -267,6 +267,20 @@ export async function respondNearbyInvite( await invoke('respond_nearby_invite', { endpointId, accept, block }) } +/** + * Accepts/declines a post-transfer pairing request. The session id comes from + * the `nearby-pair-request-received` payload and is echoed back in the + * `InviteResponse` so a late reply cannot pair a newer request. + */ +export async function respondPairRequest( + endpointId: string, + sessionId: string, + accept: boolean +): Promise { + if (!pairingCapable()) return + await invoke('respond_pair_request', { endpointId, sessionId, accept }) +} + export function formatOsLabel(os: string | undefined | null): string { switch ((os ?? '').toLowerCase()) { case 'macos':