55 LuRadioReceiver ,
66 LuCheck ,
77 LuUpload ,
8+ LuDownload ,
89} from "react-icons/lu" ;
910import { PlusCircleIcon , ExclamationTriangleIcon } from "@heroicons/react/20/solid" ;
1011import { TrashIcon } from "@heroicons/react/16/solid" ;
@@ -186,6 +187,9 @@ export function Dialog({ onClose }: Readonly<{ onClose: () => void }>) {
186187 setIncompleteFileName ( incompleteFile || null ) ;
187188 setModalView ( "upload" ) ;
188189 } }
190+ onDownloadClick = { ( ) => {
191+ setModalView ( "download" ) ;
192+ } }
189193 />
190194 ) }
191195
@@ -200,6 +204,15 @@ export function Dialog({ onClose }: Readonly<{ onClose: () => void }>) {
200204 />
201205 ) }
202206
207+ { modalView === "download" && (
208+ < DownloadFileView
209+ onBack = { ( ) => setModalView ( "device" ) }
210+ onDownloadComplete = { ( ) => {
211+ setModalView ( "device" ) ;
212+ } }
213+ />
214+ ) }
215+
203216 { modalView === "error" && (
204217 < ErrorView
205218 errorMessage = { errorMessage }
@@ -514,11 +527,13 @@ function DeviceFileView({
514527 mountInProgress,
515528 onBack,
516529 onNewImageClick,
530+ onDownloadClick,
517531} : {
518532 onMountStorageFile : ( name : string , mode : RemoteVirtualMediaState [ "mode" ] ) => void ;
519533 mountInProgress : boolean ;
520534 onBack : ( ) => void ;
521535 onNewImageClick : ( incompleteFileName ?: string ) => void ;
536+ onDownloadClick : ( ) => void ;
522537} ) {
523538 const [ onStorageFiles , setOnStorageFiles ] = useState < StorageFile [ ] > ( [ ] ) ;
524539
@@ -795,7 +810,7 @@ function DeviceFileView({
795810
796811 { onStorageFiles . length > 0 && (
797812 < div
798- className = "w-full animate-fadeIn opacity-0"
813+ className = "w-full animate-fadeIn space-y-2 opacity-0"
799814 style = { {
800815 animationDuration : "0.7s" ,
801816 animationDelay : "0.25s" ,
@@ -808,6 +823,13 @@ function DeviceFileView({
808823 text = { m . mount_button_upload_new_image ( ) }
809824 onClick = { ( ) => onNewImageClick ( ) }
810825 />
826+ < Button
827+ size = "MD"
828+ theme = "light"
829+ fullWidth
830+ text = { m . mount_button_download_from_url ( ) }
831+ onClick = { ( ) => onDownloadClick ( ) }
832+ />
811833 </ div >
812834 ) }
813835 </ div >
@@ -1243,6 +1265,272 @@ function UploadFileView({
12431265 ) ;
12441266}
12451267
1268+ function DownloadFileView ( {
1269+ onBack,
1270+ onDownloadComplete,
1271+ } : {
1272+ onBack : ( ) => void ;
1273+ onDownloadComplete : ( ) => void ;
1274+ } ) {
1275+ const [ downloadViewState , setDownloadViewState ] = useState < "idle" | "downloading" | "success" | "error" > ( "idle" ) ;
1276+ const [ url , setUrl ] = useState < string > ( "" ) ;
1277+ const [ filename , setFilename ] = useState < string > ( "" ) ;
1278+ const [ progress , setProgress ] = useState ( 0 ) ;
1279+ const [ downloadSpeed , setDownloadSpeed ] = useState < number | null > ( null ) ;
1280+ const [ downloadError , setDownloadError ] = useState < string | null > ( null ) ;
1281+ const [ totalBytes , setTotalBytes ] = useState < number > ( 0 ) ;
1282+
1283+ const { send } = useJsonRpc ( ) ;
1284+
1285+ // Track download speed
1286+ const lastBytesRef = useRef ( 0 ) ;
1287+ const lastTimeRef = useRef ( 0 ) ;
1288+ const speedHistoryRef = useRef < number [ ] > ( [ ] ) ;
1289+
1290+ // Compute URL validity
1291+ const isUrlValid = useMemo ( ( ) => {
1292+ try {
1293+ const urlObj = new URL ( url ) ;
1294+ return urlObj . protocol === 'http:' || urlObj . protocol === 'https:' ;
1295+ } catch {
1296+ return false ;
1297+ }
1298+ } , [ url ] ) ;
1299+
1300+ // Extract filename from URL
1301+ const suggestedFilename = useMemo ( ( ) => {
1302+ if ( ! url ) return '' ;
1303+ try {
1304+ const urlObj = new URL ( url ) ;
1305+ const pathParts = urlObj . pathname . split ( '/' ) ;
1306+ const lastPart = pathParts [ pathParts . length - 1 ] ;
1307+ if ( lastPart && ( lastPart . endsWith ( '.iso' ) || lastPart . endsWith ( '.img' ) ) ) {
1308+ return lastPart ;
1309+ }
1310+ } catch {
1311+ // Invalid URL, ignore
1312+ }
1313+ return '' ;
1314+ } , [ url ] ) ;
1315+
1316+ // Update filename when URL changes and user hasn't manually edited it
1317+ const [ userEditedFilename , setUserEditedFilename ] = useState ( false ) ;
1318+ const effectiveFilename = userEditedFilename ? filename : ( suggestedFilename || filename ) ;
1319+
1320+ // Listen for download state events via polling
1321+ useEffect ( ( ) => {
1322+ if ( downloadViewState !== "downloading" ) return ;
1323+
1324+ const pollInterval = setInterval ( ( ) => {
1325+ send ( "getDownloadState" , { } , ( resp : JsonRpcResponse ) => {
1326+ if ( "error" in resp ) return ;
1327+
1328+ const state = resp . result as {
1329+ downloading : boolean ;
1330+ filename : string ;
1331+ totalBytes : number ;
1332+ doneBytes : number ;
1333+ progress : number ;
1334+ error ?: string ;
1335+ } ;
1336+
1337+ if ( state . error ) {
1338+ setDownloadError ( state . error ) ;
1339+ setDownloadViewState ( "error" ) ;
1340+ return ;
1341+ }
1342+
1343+ setTotalBytes ( state . totalBytes ) ;
1344+ setProgress ( state . progress * 100 ) ;
1345+
1346+ // Calculate speed
1347+ const now = Date . now ( ) ;
1348+ const timeDiff = ( now - lastTimeRef . current ) / 1000 ;
1349+ const bytesDiff = state . doneBytes - lastBytesRef . current ;
1350+
1351+ if ( timeDiff > 0 && bytesDiff > 0 ) {
1352+ const instantSpeed = bytesDiff / timeDiff ;
1353+ speedHistoryRef . current . push ( instantSpeed ) ;
1354+ if ( speedHistoryRef . current . length > 5 ) {
1355+ speedHistoryRef . current . shift ( ) ;
1356+ }
1357+ const avgSpeed = speedHistoryRef . current . reduce ( ( a , b ) => a + b , 0 ) / speedHistoryRef . current . length ;
1358+ setDownloadSpeed ( avgSpeed ) ;
1359+ }
1360+
1361+ lastBytesRef . current = state . doneBytes ;
1362+ lastTimeRef . current = now ;
1363+
1364+ if ( ! state . downloading && state . progress >= 1 ) {
1365+ setDownloadViewState ( "success" ) ;
1366+ }
1367+ } ) ;
1368+ } , 500 ) ;
1369+
1370+ return ( ) => clearInterval ( pollInterval ) ;
1371+ } , [ downloadViewState , send ] ) ;
1372+
1373+ function handleStartDownload ( ) {
1374+ if ( ! url || ! effectiveFilename ) return ;
1375+
1376+ setDownloadViewState ( "downloading" ) ;
1377+ setDownloadError ( null ) ;
1378+ setProgress ( 0 ) ;
1379+ setDownloadSpeed ( null ) ;
1380+ lastBytesRef . current = 0 ;
1381+ lastTimeRef . current = Date . now ( ) ;
1382+ speedHistoryRef . current = [ ] ;
1383+
1384+ send ( "downloadFromUrl" , { url, filename : effectiveFilename } , ( resp : JsonRpcResponse ) => {
1385+ if ( "error" in resp ) {
1386+ setDownloadError ( resp . error . message ) ;
1387+ setDownloadViewState ( "error" ) ;
1388+ }
1389+ } ) ;
1390+ }
1391+
1392+ function handleCancelDownload ( ) {
1393+ send ( "cancelDownload" , { } , ( resp : JsonRpcResponse ) => {
1394+ if ( "error" in resp ) {
1395+ console . error ( "Failed to cancel download:" , resp . error ) ;
1396+ }
1397+ setDownloadViewState ( "idle" ) ;
1398+ } ) ;
1399+ }
1400+
1401+ return (
1402+ < div className = "w-full space-y-4" >
1403+ < ViewHeader
1404+ title = { m . mount_download_title ( ) }
1405+ description = { m . mount_download_description ( ) }
1406+ />
1407+
1408+ { downloadViewState === "idle" && (
1409+ < >
1410+ < div className = "animate-fadeIn space-y-4 opacity-0" style = { { animationDuration : "0.7s" } } >
1411+ < InputFieldWithLabel
1412+ placeholder = "https://example.com/image.iso"
1413+ type = "url"
1414+ label = { m . mount_download_url_label ( ) }
1415+ value = { url }
1416+ onChange = { e => setUrl ( e . target . value ) }
1417+ />
1418+ < InputFieldWithLabel
1419+ placeholder = "image.iso"
1420+ type = "text"
1421+ label = { m . mount_download_filename_label ( ) }
1422+ value = { effectiveFilename }
1423+ onChange = { e => {
1424+ setFilename ( e . target . value ) ;
1425+ setUserEditedFilename ( true ) ;
1426+ } }
1427+ />
1428+ </ div >
1429+ < div className = "flex w-full justify-end space-x-2" >
1430+ < Button size = "MD" theme = "blank" text = { m . back ( ) } onClick = { onBack } />
1431+ < Button
1432+ size = "MD"
1433+ theme = "primary"
1434+ text = { m . mount_button_start_download ( ) }
1435+ onClick = { handleStartDownload }
1436+ disabled = { ! isUrlValid || ! effectiveFilename }
1437+ />
1438+ </ div >
1439+ </ >
1440+ ) }
1441+
1442+ { downloadViewState === "downloading" && (
1443+ < div className = "animate-fadeIn space-y-4 opacity-0" style = { { animationDuration : "0.7s" } } >
1444+ < Card >
1445+ < div className = "p-4 space-y-3" >
1446+ < div className = "flex items-center gap-2" >
1447+ < LuDownload className = "h-5 w-5 text-blue-500 animate-pulse" />
1448+ < h3 className = "text-lg font-semibold dark:text-white" >
1449+ { m . mount_downloading_with_name ( { name : formatters . truncateMiddle ( effectiveFilename , 30 ) } ) }
1450+ </ h3 >
1451+ </ div >
1452+ < p className = "text-sm text-slate-600 dark:text-slate-400" >
1453+ { formatters . bytes ( totalBytes ) }
1454+ </ p >
1455+ < div className = "h-3.5 w-full overflow-hidden rounded-full bg-slate-300 dark:bg-slate-700" >
1456+ < div
1457+ className = "h-3.5 rounded-full bg-blue-700 transition-all duration-500 ease-linear dark:bg-blue-500"
1458+ style = { { width : `${ progress } %` } }
1459+ />
1460+ </ div >
1461+ < div className = "flex justify-between text-xs text-slate-600 dark:text-slate-400" >
1462+ < span > { m . mount_downloading ( ) } </ span >
1463+ < span >
1464+ { downloadSpeed !== null
1465+ ? `${ formatters . bytes ( downloadSpeed ) } /s`
1466+ : m . mount_calculating ( ) }
1467+ </ span >
1468+ </ div >
1469+ </ div >
1470+ </ Card >
1471+ < div className = "flex w-full justify-end" >
1472+ < Button
1473+ size = "MD"
1474+ theme = "light"
1475+ text = { m . mount_button_cancel_download ( ) }
1476+ onClick = { handleCancelDownload }
1477+ />
1478+ </ div >
1479+ </ div >
1480+ ) }
1481+
1482+ { downloadViewState === "success" && (
1483+ < div className = "animate-fadeIn space-y-4 opacity-0" style = { { animationDuration : "0.7s" } } >
1484+ < Card >
1485+ < div className = "p-4 text-center space-y-2" >
1486+ < LuCheck className = "h-8 w-8 text-green-500 mx-auto" />
1487+ < h3 className = "text-lg font-semibold dark:text-white" >
1488+ { m . mount_download_successful ( ) }
1489+ </ h3 >
1490+ < p className = "text-sm text-slate-600 dark:text-slate-400" >
1491+ { m . mount_download_has_been_downloaded ( { name : effectiveFilename } ) }
1492+ </ p >
1493+ </ div >
1494+ </ Card >
1495+ < div className = "flex w-full justify-end" >
1496+ < Button
1497+ size = "MD"
1498+ theme = "primary"
1499+ text = { m . mount_button_back_to_overview ( ) }
1500+ onClick = { onDownloadComplete }
1501+ />
1502+ </ div >
1503+ </ div >
1504+ ) }
1505+
1506+ { downloadViewState === "error" && (
1507+ < div className = "animate-fadeIn space-y-4 opacity-0" style = { { animationDuration : "0.7s" } } >
1508+ < Card className = "border border-red-200 bg-red-50 dark:bg-red-900/20" >
1509+ < div className = "p-4 text-center space-y-2" >
1510+ < ExclamationTriangleIcon className = "h-8 w-8 text-red-500 mx-auto" />
1511+ < h3 className = "text-lg font-semibold text-red-800 dark:text-red-400" >
1512+ { m . mount_error_title ( ) }
1513+ </ h3 >
1514+ < p className = "text-sm text-red-600 dark:text-red-400" >
1515+ { downloadError }
1516+ </ p >
1517+ </ div >
1518+ </ Card >
1519+ < div className = "flex w-full justify-end space-x-2" >
1520+ < Button size = "MD" theme = "light" text = { m . back ( ) } onClick = { onBack } />
1521+ < Button
1522+ size = "MD"
1523+ theme = "primary"
1524+ text = { m . mount_button_back_to_overview ( ) }
1525+ onClick = { ( ) => setDownloadViewState ( "idle" ) }
1526+ />
1527+ </ div >
1528+ </ div >
1529+ ) }
1530+ </ div >
1531+ ) ;
1532+ }
1533+
12461534function ErrorView ( {
12471535 errorMessage,
12481536 onClose,
0 commit comments