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 }
@@ -508,11 +521,13 @@ function DeviceFileView({
508521 mountInProgress,
509522 onBack,
510523 onNewImageClick,
524+ onDownloadClick,
511525} : {
512526 onMountStorageFile : ( name : string , mode : RemoteVirtualMediaState [ "mode" ] ) => void ;
513527 mountInProgress : boolean ;
514528 onBack : ( ) => void ;
515529 onNewImageClick : ( incompleteFileName ?: string ) => void ;
530+ onDownloadClick : ( ) => void ;
516531} ) {
517532 const [ onStorageFiles , setOnStorageFiles ] = useState <
518533 {
@@ -799,7 +814,7 @@ function DeviceFileView({
799814
800815 { onStorageFiles . length > 0 && (
801816 < div
802- className = "w-full animate-fadeIn opacity-0"
817+ className = "w-full animate-fadeIn space-y-2 opacity-0"
803818 style = { {
804819 animationDuration : "0.7s" ,
805820 animationDelay : "0.25s" ,
@@ -812,6 +827,13 @@ function DeviceFileView({
812827 text = { m . mount_button_upload_new_image ( ) }
813828 onClick = { ( ) => onNewImageClick ( ) }
814829 />
830+ < Button
831+ size = "MD"
832+ theme = "light"
833+ fullWidth
834+ text = { m . mount_button_download_from_url ( ) }
835+ onClick = { ( ) => onDownloadClick ( ) }
836+ />
815837 </ div >
816838 ) }
817839 </ div >
@@ -1247,6 +1269,272 @@ function UploadFileView({
12471269 ) ;
12481270}
12491271
1272+ function DownloadFileView ( {
1273+ onBack,
1274+ onDownloadComplete,
1275+ } : {
1276+ onBack : ( ) => void ;
1277+ onDownloadComplete : ( ) => void ;
1278+ } ) {
1279+ const [ downloadViewState , setDownloadViewState ] = useState < "idle" | "downloading" | "success" | "error" > ( "idle" ) ;
1280+ const [ url , setUrl ] = useState < string > ( "" ) ;
1281+ const [ filename , setFilename ] = useState < string > ( "" ) ;
1282+ const [ progress , setProgress ] = useState ( 0 ) ;
1283+ const [ downloadSpeed , setDownloadSpeed ] = useState < number | null > ( null ) ;
1284+ const [ downloadError , setDownloadError ] = useState < string | null > ( null ) ;
1285+ const [ totalBytes , setTotalBytes ] = useState < number > ( 0 ) ;
1286+
1287+ const { send } = useJsonRpc ( ) ;
1288+
1289+ // Track download speed
1290+ const lastBytesRef = useRef ( 0 ) ;
1291+ const lastTimeRef = useRef ( 0 ) ;
1292+ const speedHistoryRef = useRef < number [ ] > ( [ ] ) ;
1293+
1294+ // Compute URL validity
1295+ const isUrlValid = useMemo ( ( ) => {
1296+ try {
1297+ const urlObj = new URL ( url ) ;
1298+ return urlObj . protocol === 'http:' || urlObj . protocol === 'https:' ;
1299+ } catch {
1300+ return false ;
1301+ }
1302+ } , [ url ] ) ;
1303+
1304+ // Extract filename from URL
1305+ const suggestedFilename = useMemo ( ( ) => {
1306+ if ( ! url ) return '' ;
1307+ try {
1308+ const urlObj = new URL ( url ) ;
1309+ const pathParts = urlObj . pathname . split ( '/' ) ;
1310+ const lastPart = pathParts [ pathParts . length - 1 ] ;
1311+ if ( lastPart && ( lastPart . endsWith ( '.iso' ) || lastPart . endsWith ( '.img' ) ) ) {
1312+ return lastPart ;
1313+ }
1314+ } catch {
1315+ // Invalid URL, ignore
1316+ }
1317+ return '' ;
1318+ } , [ url ] ) ;
1319+
1320+ // Update filename when URL changes and user hasn't manually edited it
1321+ const [ userEditedFilename , setUserEditedFilename ] = useState ( false ) ;
1322+ const effectiveFilename = userEditedFilename ? filename : ( suggestedFilename || filename ) ;
1323+
1324+ // Listen for download state events via polling
1325+ useEffect ( ( ) => {
1326+ if ( downloadViewState !== "downloading" ) return ;
1327+
1328+ const pollInterval = setInterval ( ( ) => {
1329+ send ( "getDownloadState" , { } , ( resp : JsonRpcResponse ) => {
1330+ if ( "error" in resp ) return ;
1331+
1332+ const state = resp . result as {
1333+ downloading : boolean ;
1334+ filename : string ;
1335+ totalBytes : number ;
1336+ doneBytes : number ;
1337+ progress : number ;
1338+ error ?: string ;
1339+ } ;
1340+
1341+ if ( state . error ) {
1342+ setDownloadError ( state . error ) ;
1343+ setDownloadViewState ( "error" ) ;
1344+ return ;
1345+ }
1346+
1347+ setTotalBytes ( state . totalBytes ) ;
1348+ setProgress ( state . progress * 100 ) ;
1349+
1350+ // Calculate speed
1351+ const now = Date . now ( ) ;
1352+ const timeDiff = ( now - lastTimeRef . current ) / 1000 ;
1353+ const bytesDiff = state . doneBytes - lastBytesRef . current ;
1354+
1355+ if ( timeDiff > 0 && bytesDiff > 0 ) {
1356+ const instantSpeed = bytesDiff / timeDiff ;
1357+ speedHistoryRef . current . push ( instantSpeed ) ;
1358+ if ( speedHistoryRef . current . length > 5 ) {
1359+ speedHistoryRef . current . shift ( ) ;
1360+ }
1361+ const avgSpeed = speedHistoryRef . current . reduce ( ( a , b ) => a + b , 0 ) / speedHistoryRef . current . length ;
1362+ setDownloadSpeed ( avgSpeed ) ;
1363+ }
1364+
1365+ lastBytesRef . current = state . doneBytes ;
1366+ lastTimeRef . current = now ;
1367+
1368+ if ( ! state . downloading && state . progress >= 1 ) {
1369+ setDownloadViewState ( "success" ) ;
1370+ }
1371+ } ) ;
1372+ } , 500 ) ;
1373+
1374+ return ( ) => clearInterval ( pollInterval ) ;
1375+ } , [ downloadViewState , send ] ) ;
1376+
1377+ function handleStartDownload ( ) {
1378+ if ( ! url || ! effectiveFilename ) return ;
1379+
1380+ setDownloadViewState ( "downloading" ) ;
1381+ setDownloadError ( null ) ;
1382+ setProgress ( 0 ) ;
1383+ setDownloadSpeed ( null ) ;
1384+ lastBytesRef . current = 0 ;
1385+ lastTimeRef . current = Date . now ( ) ;
1386+ speedHistoryRef . current = [ ] ;
1387+
1388+ send ( "downloadFromUrl" , { url, filename : effectiveFilename } , ( resp : JsonRpcResponse ) => {
1389+ if ( "error" in resp ) {
1390+ setDownloadError ( resp . error . message ) ;
1391+ setDownloadViewState ( "error" ) ;
1392+ }
1393+ } ) ;
1394+ }
1395+
1396+ function handleCancelDownload ( ) {
1397+ send ( "cancelDownload" , { } , ( resp : JsonRpcResponse ) => {
1398+ if ( "error" in resp ) {
1399+ console . error ( "Failed to cancel download:" , resp . error ) ;
1400+ }
1401+ setDownloadViewState ( "idle" ) ;
1402+ } ) ;
1403+ }
1404+
1405+ return (
1406+ < div className = "w-full space-y-4" >
1407+ < ViewHeader
1408+ title = { m . mount_download_title ( ) }
1409+ description = { m . mount_download_description ( ) }
1410+ />
1411+
1412+ { downloadViewState === "idle" && (
1413+ < >
1414+ < div className = "animate-fadeIn space-y-4 opacity-0" style = { { animationDuration : "0.7s" } } >
1415+ < InputFieldWithLabel
1416+ placeholder = "https://example.com/image.iso"
1417+ type = "url"
1418+ label = { m . mount_download_url_label ( ) }
1419+ value = { url }
1420+ onChange = { e => setUrl ( e . target . value ) }
1421+ />
1422+ < InputFieldWithLabel
1423+ placeholder = "image.iso"
1424+ type = "text"
1425+ label = { m . mount_download_filename_label ( ) }
1426+ value = { effectiveFilename }
1427+ onChange = { e => {
1428+ setFilename ( e . target . value ) ;
1429+ setUserEditedFilename ( true ) ;
1430+ } }
1431+ />
1432+ </ div >
1433+ < div className = "flex w-full justify-end space-x-2" >
1434+ < Button size = "MD" theme = "blank" text = { m . back ( ) } onClick = { onBack } />
1435+ < Button
1436+ size = "MD"
1437+ theme = "primary"
1438+ text = { m . mount_button_start_download ( ) }
1439+ onClick = { handleStartDownload }
1440+ disabled = { ! isUrlValid || ! effectiveFilename }
1441+ />
1442+ </ div >
1443+ </ >
1444+ ) }
1445+
1446+ { downloadViewState === "downloading" && (
1447+ < div className = "animate-fadeIn space-y-4 opacity-0" style = { { animationDuration : "0.7s" } } >
1448+ < Card >
1449+ < div className = "p-4 space-y-3" >
1450+ < div className = "flex items-center gap-2" >
1451+ < LuDownload className = "h-5 w-5 text-blue-500 animate-pulse" />
1452+ < h3 className = "text-lg font-semibold dark:text-white" >
1453+ { m . mount_downloading_with_name ( { name : formatters . truncateMiddle ( effectiveFilename , 30 ) } ) }
1454+ </ h3 >
1455+ </ div >
1456+ < p className = "text-sm text-slate-600 dark:text-slate-400" >
1457+ { formatters . bytes ( totalBytes ) }
1458+ </ p >
1459+ < div className = "h-3.5 w-full overflow-hidden rounded-full bg-slate-300 dark:bg-slate-700" >
1460+ < div
1461+ className = "h-3.5 rounded-full bg-blue-700 transition-all duration-500 ease-linear dark:bg-blue-500"
1462+ style = { { width : `${ progress } %` } }
1463+ />
1464+ </ div >
1465+ < div className = "flex justify-between text-xs text-slate-600 dark:text-slate-400" >
1466+ < span > { m . mount_downloading ( ) } </ span >
1467+ < span >
1468+ { downloadSpeed !== null
1469+ ? `${ formatters . bytes ( downloadSpeed ) } /s`
1470+ : m . mount_calculating ( ) }
1471+ </ span >
1472+ </ div >
1473+ </ div >
1474+ </ Card >
1475+ < div className = "flex w-full justify-end" >
1476+ < Button
1477+ size = "MD"
1478+ theme = "light"
1479+ text = { m . mount_button_cancel_download ( ) }
1480+ onClick = { handleCancelDownload }
1481+ />
1482+ </ div >
1483+ </ div >
1484+ ) }
1485+
1486+ { downloadViewState === "success" && (
1487+ < div className = "animate-fadeIn space-y-4 opacity-0" style = { { animationDuration : "0.7s" } } >
1488+ < Card >
1489+ < div className = "p-4 text-center space-y-2" >
1490+ < LuCheck className = "h-8 w-8 text-green-500 mx-auto" />
1491+ < h3 className = "text-lg font-semibold dark:text-white" >
1492+ { m . mount_download_successful ( ) }
1493+ </ h3 >
1494+ < p className = "text-sm text-slate-600 dark:text-slate-400" >
1495+ { m . mount_download_has_been_downloaded ( { name : effectiveFilename } ) }
1496+ </ p >
1497+ </ div >
1498+ </ Card >
1499+ < div className = "flex w-full justify-end" >
1500+ < Button
1501+ size = "MD"
1502+ theme = "primary"
1503+ text = { m . mount_button_back_to_overview ( ) }
1504+ onClick = { onDownloadComplete }
1505+ />
1506+ </ div >
1507+ </ div >
1508+ ) }
1509+
1510+ { downloadViewState === "error" && (
1511+ < div className = "animate-fadeIn space-y-4 opacity-0" style = { { animationDuration : "0.7s" } } >
1512+ < Card className = "border border-red-200 bg-red-50 dark:bg-red-900/20" >
1513+ < div className = "p-4 text-center space-y-2" >
1514+ < ExclamationTriangleIcon className = "h-8 w-8 text-red-500 mx-auto" />
1515+ < h3 className = "text-lg font-semibold text-red-800 dark:text-red-400" >
1516+ { m . mount_error_title ( ) }
1517+ </ h3 >
1518+ < p className = "text-sm text-red-600 dark:text-red-400" >
1519+ { downloadError }
1520+ </ p >
1521+ </ div >
1522+ </ Card >
1523+ < div className = "flex w-full justify-end space-x-2" >
1524+ < Button size = "MD" theme = "light" text = { m . back ( ) } onClick = { onBack } />
1525+ < Button
1526+ size = "MD"
1527+ theme = "primary"
1528+ text = { m . mount_button_back_to_overview ( ) }
1529+ onClick = { ( ) => setDownloadViewState ( "idle" ) }
1530+ />
1531+ </ div >
1532+ </ div >
1533+ ) }
1534+ </ div >
1535+ ) ;
1536+ }
1537+
12501538function ErrorView ( {
12511539 errorMessage,
12521540 onClose,
0 commit comments