import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { LuGlobe, LuLink, LuRadioReceiver, LuHardDrive, LuCheck, LuUpload, LuRefreshCw, LuDownload, } from "react-icons/lu"; import { PlusCircleIcon, ExclamationTriangleIcon } from "@heroicons/react/20/solid"; import { TrashIcon } from "@heroicons/react/16/solid"; import { useNavigate } from "react-router-dom"; import Card, { GridCard } from "@/components/Card"; import { Button } from "@components/Button"; import LogoLuckfox from "@/assets/logo-luckfox.png"; import { formatters } from "@/utils"; import AutoHeight from "@components/AutoHeight"; import { DEVICE_API } from "@/ui.config"; import { useJsonRpc } from "../hooks/useJsonRpc"; import notifications from "../notifications"; import { isOnDevice } from "../main"; import { cx } from "../cva.config"; import { useMountMediaStore, useRTCStore, } from "../hooks/stores"; import { UploadDialog } from "@/components/UploadDialog"; import { sync } from "framer-motion"; import Fieldset from "@/components/Fieldset"; export default function MtpRoute() { const navigate = useNavigate(); { /* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */ } return navigate("..")} />; } export function Dialog({ onClose }: { onClose: () => void }) { const { modalView, setModalView, errorMessage, setErrorMessage, } = useMountMediaStore(); const navigate = useNavigate(); const [incompleteFileName, setIncompleteFileName] = useState(null); const [send] = useJsonRpc(); const [selectedMode, setSelectedMode] = useState< "mtp_device" | "mtp_sd" >("mtp_device"); return (
KVM Logo KVM Logo {modalView === "mode" && ( onClose()} selectedMode={selectedMode} setSelectedMode={setSelectedMode} /> )} {modalView === "mtp_device" && ( { setModalView("mode"); }} onNewImageClick={incompleteFile => { setIncompleteFileName(incompleteFile || null); setModalView("upload"); }} /> )} {modalView === "mtp_sd" && ( { setModalView("mode"); }} onNewImageClick={incompleteFile => { setIncompleteFileName(incompleteFile || null); setModalView("upload_sd"); }} /> )} {modalView === "upload" && ( setModalView("mtp_device")} onCancelUpload={() => { setModalView("mtp_device"); // Implement cancel upload logic here }} incompleteFileName={incompleteFileName || undefined} media="local" /> )} {modalView === "upload_sd" && ( setModalView("mtp_sd")} onCancelUpload={() => { setModalView("mtp_sd"); // Implement cancel upload logic here }} incompleteFileName={incompleteFileName || undefined} media="sd" /> )} {modalView === "error" && ( { onClose(); setErrorMessage(null); }} onRetry={() => { setModalView("mode"); setErrorMessage(null); }} /> )}
); } function MtpModeSelectionView({ onClose, selectedMode, setSelectedMode, }: { onClose: () => void; selectedMode: "mtp_device" | "mtp_sd"; setSelectedMode: (mode: "mtp_device" | "mtp_sd") => void; }) { const { setModalView } = useMountMediaStore(); return (

Virtual Media Source

Choose how you want to mount your virtual media
{[ { label: "KVM Storage Manager", value: "mtp_device", description: "Manage the shared folder located on eMMC", icon: LuRadioReceiver, tag: null, disabled: false, }, { label: "KVM MicroSD Manager", value: "mtp_sd", description: "Manage the shared folder located on MicroSD", icon: LuRadioReceiver, tag: null, disabled: false, }, ].map(({ label, description, value: mode, icon: Icon, tag, disabled }, index) => (
disabled ? null : setSelectedMode(mode as "mtp_device" | "mtp_sd") } >

{tag ? tag : <> }

{label}

{description}

))}
); } function DeviceFileView({ onBack, onNewImageClick, }: { onBack: () => void; onNewImageClick: (incompleteFileName?: string) => void; }) { const [onStorageFiles, setOnStorageFiles] = useState< { name: string; size: string; createdAt: string; }[] >([]); const [selected, setSelected] = useState(null); const [currentPage, setCurrentPage] = useState(1); const filesPerPage = 5; const [send] = useJsonRpc(); interface StorageSpace { bytesUsed: number; bytesFree: number; } const [storageSpace, setStorageSpace] = useState(null); const percentageUsed = useMemo(() => { if (!storageSpace) return 0; return Number( ( (storageSpace.bytesUsed / (storageSpace.bytesUsed + storageSpace.bytesFree)) * 100 ).toFixed(1), ); }, [storageSpace]); const bytesUsed = useMemo(() => { if (!storageSpace) return 0; return storageSpace.bytesUsed; }, [storageSpace]); const bytesFree = useMemo(() => { if (!storageSpace) return 0; return storageSpace.bytesFree; }, [storageSpace]); const syncStorage = useCallback(() => { send("listStorageFiles", {}, res => { if ("error" in res) { notifications.error(`Error listing storage files: ${res.error}`); return; } const { files } = res.result as StorageFiles; const formattedFiles = files.map(file => ({ name: file.filename, size: formatters.bytes(file.size), createdAt: formatters.date(new Date(file?.createdAt)), })); setOnStorageFiles(formattedFiles); }); send("getStorageSpace", {}, res => { if ("error" in res) { notifications.error(`Error getting storage space: ${res.error}`); return; } const space = res.result as StorageSpace; setStorageSpace(space); }); }, [send, setOnStorageFiles, setStorageSpace]); useEffect(() => { syncStorage(); }, [syncStorage]); interface StorageFiles { files: { filename: string; size: number; createdAt: string; }[]; } useEffect(() => { syncStorage(); }, [syncStorage]); function handleDeleteFile(file: { name: string; size: string; createdAt: string }) { console.log("Deleting file:", file); send("deleteStorageFile", { filename: file.name }, res => { if ("error" in res) { notifications.error(`Error deleting file: ${res.error}`); return; } syncStorage(); }); } function handleDownloadFile(file: { name: string }) { const downloadUrl = `${DEVICE_API}/storage/download?file=${encodeURIComponent(file.name)}`; const a = document.createElement("a"); a.href = downloadUrl; a.download = file.name; document.body.appendChild(a); a.click(); a.remove(); } const indexOfLastFile = currentPage * filesPerPage; const indexOfFirstFile = indexOfLastFile - filesPerPage; const currentFiles = onStorageFiles.slice(indexOfFirstFile, indexOfLastFile); const totalPages = Math.ceil(onStorageFiles.length / filesPerPage); const handlePreviousPage = () => { setCurrentPage(prev => Math.max(prev - 1, 1)); }; const handleNextPage = () => { setCurrentPage(prev => Math.min(prev + 1, totalPages)); }; return (
{onStorageFiles.length === 0 ? (

No images available

Upload an image to start virtual media mounting.

) : (
{currentFiles.map((file, index) => ( { const selectedFile = onStorageFiles.find(f => f.name === file.name); if (!selectedFile) return; if ( window.confirm( "Are you sure you want to download " + selectedFile.name + "?", ) ) { handleDownloadFile(selectedFile); } }} onDelete={() => { const selectedFile = onStorageFiles.find(f => f.name === file.name); if (!selectedFile) return; if ( window.confirm( "Are you sure you want to delete " + selectedFile.name + "?", ) ) { handleDeleteFile(selectedFile); } }} onContinueUpload={() => onNewImageClick(file.name)} /> ))} {onStorageFiles.length > filesPerPage && (

Showing {indexOfFirstFile + 1} to{" "} {Math.min(indexOfLastFile, onStorageFiles.length)} {" "} of {onStorageFiles.length} results

)}
)}
{onStorageFiles.length > 0 ? (
) : (
)}
Available Storage {percentageUsed}% used
{formatters.bytes(bytesUsed)} used {formatters.bytes(bytesFree)} free
{onStorageFiles.length > 0 && (
)}
); } function SDFileView({ onBack, onNewImageClick, }: { onBack: () => void; onNewImageClick: (incompleteFileName?: string) => void; }) { const [onStorageFiles, setOnStorageFiles] = useState< { name: string; size: string; createdAt: string; }[] >([]); const [sdMountStatus, setSDMountStatus] = useState<"ok" | "none" | "fail" | null>(null); const [selected, setSelected] = useState(null); const [currentPage, setCurrentPage] = useState(1); const [loading, setLoading] = useState(false); const filesPerPage = 5; const [send] = useJsonRpc(); interface StorageSpace { bytesUsed: number; bytesFree: number; } const [storageSpace, setStorageSpace] = useState(null); const percentageUsed = useMemo(() => { if (!storageSpace) return 0; return Number( ( (storageSpace.bytesUsed / (storageSpace.bytesUsed + storageSpace.bytesFree)) * 100 ).toFixed(1), ); }, [storageSpace]); const bytesUsed = useMemo(() => { if (!storageSpace) return 0; return storageSpace.bytesUsed; }, [storageSpace]); const bytesFree = useMemo(() => { if (!storageSpace) return 0; return storageSpace.bytesFree; }, [storageSpace]); const syncStorage = useCallback(() => { send("getSDMountStatus", {}, res => { if ("error" in res) { notifications.error(`Failed to check SD card status: ${res.error}`); setSDMountStatus(null); return; } const { status } = res.result as { status: "ok" | "none" | "fail" }; setSDMountStatus(status); if (status === "none") { notifications.error("No SD card detected, please insert an SD card"); return; } if (status === "fail") { notifications.error("SD card mount failed, please format the SD card"); return; } send("listSDStorageFiles", {}, res => { if ("error" in res) { notifications.error(`Error listing SD storage files: ${res.error}`); return; } const { files } = res.result as StorageFiles; const formattedFiles = files.map(file => ({ name: file.filename, size: formatters.bytes(file.size), createdAt: formatters.date(new Date(file?.createdAt)), })); setOnStorageFiles(formattedFiles); console.log("SD storage files:", formattedFiles); }); send("getSDStorageSpace", {}, res => { if ("error" in res) { notifications.error(`Error getting SD storage space: ${res.error}`); return; } const space = res.result as StorageSpace; setStorageSpace(space); }); }); }, [send]); useEffect(() => { syncStorage(); }, [syncStorage]); interface StorageFiles { files: { filename: string; size: number; createdAt: string; }[]; } useEffect(() => { syncStorage(); }, [syncStorage]); function handleSDDeleteFile(file: { name: string; size: string; createdAt: string }) { console.log("Deleting file:", file); send("deleteSDStorageFile", { filename: file.name }, res => { if ("error" in res) { notifications.error(`Error deleting file: ${res.error}`); return; } syncStorage(); }); } function handleSDDownloadFile(file: { name: string }) { const downloadUrl = `${DEVICE_API}/storage/sd-download?file=${encodeURIComponent(file.name)}`; const a = document.createElement("a"); a.href = downloadUrl; a.download = file.name; document.body.appendChild(a); a.click(); a.remove(); } const indexOfLastFile = currentPage * filesPerPage; const indexOfFirstFile = indexOfLastFile - filesPerPage; const currentFiles = onStorageFiles.slice(indexOfFirstFile, indexOfLastFile); const totalPages = Math.ceil(onStorageFiles.length / filesPerPage); const handlePreviousPage = () => { setCurrentPage(prev => Math.max(prev - 1, 1)); }; const handleNextPage = () => { setCurrentPage(prev => Math.min(prev + 1, totalPages)); }; async function handleResetSDStorage() { setLoading(true); send("resetSDStorage", {}, res => { console.log("Reset SD storage response:", res); if ("error" in res) { notifications.error(`Failed to reset SD card`); setLoading(false); return; } }); await new Promise(resolve => setTimeout(resolve, 2000)); setLoading(false); syncStorage(); } async function handleUnmountSDStorage() { setLoading(true); send("unmountSDStorage", {}, res => { console.log("Unmount SD response:", res); if ("error" in res) { notifications.error(`Failed to unmount SD card`); setLoading(false); return; } }); await new Promise(resolve => setTimeout(resolve, 2000)); setLoading(false); syncStorage(); } async function handleMountSDStorage() { setLoading(true); send("mountSDStorage", {}, res => { console.log("Mount SD response:", res); if ("error" in res) { notifications.error(`Failed to mount SD card`); setLoading(false); return; } }); await new Promise(resolve => setTimeout(resolve, 2000)); setLoading(false); syncStorage(); } if (sdMountStatus && sdMountStatus !== "ok") { return (

{sdMountStatus === "none" ? "No SD card detected" : "SD card mount failed"}

{sdMountStatus === "none" ? "Please insert an SD card and try again." : "Please format the SD card and try again."}

); } return (
{onStorageFiles.length === 0 ? (

No images available

Upload a file.

) : (
{currentFiles.map((file, index) => ( { const selectedFile = onStorageFiles.find(f => f.name === file.name); if (!selectedFile) return; if ( window.confirm( "Are you sure you want to download " + selectedFile.name + "?", ) ) { handleSDDownloadFile(selectedFile); } }} onDelete={() => { const selectedFile = onStorageFiles.find(f => f.name === file.name); if (!selectedFile) return; if (window.confirm("Are you sure you want to delete " + selectedFile.name + "?")) { handleSDDeleteFile(selectedFile); } }} onContinueUpload={() => onNewImageClick(file.name)} /> ))} {onStorageFiles.length > filesPerPage && (

Showing {indexOfFirstFile + 1} to{" "} {Math.min(indexOfLastFile, onStorageFiles.length)} {" "} of {onStorageFiles.length} results

)}
)}
{onStorageFiles.length > 0 ? (
) : (
)}
Available Storage {percentageUsed}% used
{formatters.bytes(bytesUsed)} used {formatters.bytes(bytesFree)} free
{onStorageFiles.length > 0 && (
)}
); } function UploadFileView({ onBack, onCancelUpload, incompleteFileName, media, }: { onBack: () => void; onCancelUpload: () => void; incompleteFileName?: string; media?: string; }) { const [uploadState, setUploadState] = useState<"idle" | "uploading" | "success">( "idle", ); const [uploadProgress, setUploadProgress] = useState(0); const [uploadedFileName, setUploadedFileName] = useState(null); const [uploadedFileSize, setUploadedFileSize] = useState(null); const [uploadSpeed, setUploadSpeed] = useState(null); const [fileError, setFileError] = useState(null); const [uploadError, setUploadError] = useState(null); const [send] = useJsonRpc(); const rtcDataChannelRef = useRef(null); useEffect(() => { const ref = rtcDataChannelRef.current; return () => { console.log("unmounting"); if (ref) { ref.onopen = null; ref.onerror = null; ref.onmessage = null; ref.onclose = null; ref.close(); } }; }, []); function handleWebRTCUpload( file: File, alreadyUploadedBytes: number, dataChannel: string, ) { const rtcDataChannel = useRTCStore .getState() .peerConnection?.createDataChannel(dataChannel); if (!rtcDataChannel) { console.error("Failed to create data channel for file upload"); notifications.error("Failed to create data channel for file upload"); setUploadState("idle"); console.log("Upload state set to 'idle'"); return; } rtcDataChannelRef.current = rtcDataChannel; const lowWaterMark = 256 * 1024; const highWaterMark = 1 * 1024 * 1024; rtcDataChannel.bufferedAmountLowThreshold = lowWaterMark; let lastUploadedBytes = alreadyUploadedBytes; let lastUpdateTime = Date.now(); const speedHistory: number[] = []; rtcDataChannel.onmessage = e => { try { const { AlreadyUploadedBytes, Size } = JSON.parse(e.data) as { AlreadyUploadedBytes: number; Size: number; }; const now = Date.now(); const timeDiff = (now - lastUpdateTime) / 1000; // in seconds const bytesDiff = AlreadyUploadedBytes - lastUploadedBytes; if (timeDiff > 0) { const instantSpeed = bytesDiff / timeDiff; // bytes per second // Add to speed history, keeping last 5 readings speedHistory.push(instantSpeed); if (speedHistory.length > 5) { speedHistory.shift(); } // Calculate average speed const averageSpeed = speedHistory.reduce((a, b) => a + b, 0) / speedHistory.length; setUploadSpeed(averageSpeed); setUploadProgress((AlreadyUploadedBytes / Size) * 100); } lastUploadedBytes = AlreadyUploadedBytes; lastUpdateTime = now; } catch (e) { console.error("Error processing RTC Data channel message:", e); } }; rtcDataChannel.onopen = () => { let pauseSending = false; // Pause sending when the buffered amount is high const chunkSize = 4 * 1024; // 4KB chunks let offset = alreadyUploadedBytes; const sendNextChunk = () => { if (offset >= file.size) { rtcDataChannel.close(); setUploadState("success"); return; } if (pauseSending) return; const chunk = file.slice(offset, offset + chunkSize); chunk.arrayBuffer().then(buffer => { rtcDataChannel.send(buffer); if (rtcDataChannel.bufferedAmount >= highWaterMark) { pauseSending = true; } offset += buffer.byteLength; console.log(`Chunk sent: ${offset} / ${file.size} bytes`); sendNextChunk(); }); }; sendNextChunk(); rtcDataChannel.onbufferedamountlow = () => { console.log("RTC Data channel buffered amount low"); pauseSending = false; // Now the data channel is ready to send more data sendNextChunk(); }; }; rtcDataChannel.onerror = error => { console.error("RTC Data channel error:", error); notifications.error(`Upload failed: ${error}`); setUploadState("idle"); console.log("Upload state set to 'idle'"); }; } async function handleHttpUpload( file: File, alreadyUploadedBytes: number, dataChannel: string, ) { const uploadUrl = `${DEVICE_API}/storage/upload?uploadId=${dataChannel}`; const xhr = new XMLHttpRequest(); xhr.open("POST", uploadUrl, true); let lastUploadedBytes = alreadyUploadedBytes; let lastUpdateTime = Date.now(); const speedHistory: number[] = []; xhr.upload.onprogress = event => { if (event.lengthComputable) { const totalUploaded = alreadyUploadedBytes + event.loaded; const totalSize = file.size; const now = Date.now(); const timeDiff = (now - lastUpdateTime) / 1000; // in seconds const bytesDiff = totalUploaded - lastUploadedBytes; if (timeDiff > 0) { const instantSpeed = bytesDiff / timeDiff; // bytes per second // Add to speed history, keeping last 5 readings speedHistory.push(instantSpeed); if (speedHistory.length > 5) { speedHistory.shift(); } // Calculate average speed const averageSpeed = speedHistory.reduce((a, b) => a + b, 0) / speedHistory.length; setUploadSpeed(averageSpeed); setUploadProgress((totalUploaded / totalSize) * 100); } lastUploadedBytes = totalUploaded; lastUpdateTime = now; } }; xhr.onload = () => { if (xhr.status === 200) { setUploadState("success"); } else { console.error("Upload error:", xhr.statusText); setUploadError(xhr.statusText); setUploadState("idle"); } }; xhr.onerror = () => { console.error("XHR error:", xhr.statusText); setUploadError(xhr.statusText); setUploadState("idle"); }; // Prepare the data to send const blob = file.slice(alreadyUploadedBytes); // Send the file data xhr.send(blob); } const handleFileChange = (event: React.ChangeEvent) => { const file = event.target.files?.[0]; if (file) { // Reset the upload error when a new file is selected setUploadError(null); if ( incompleteFileName && file.name !== incompleteFileName.replace(".incomplete", "") ) { setFileError( `Please select the file "${incompleteFileName.replace(".incomplete", "")}" to continue the upload.`, ); return; } setFileError(null); console.log(`File selected: ${file.name}, size: ${file.size} bytes`); setUploadedFileName(file.name); setUploadedFileSize(file.size); setUploadState("uploading"); console.log("Upload state set to 'uploading'"); if ( media === "sd" ) { send("startSDStorageFileUpload", { filename: file.name, size: file.size }, resp => { console.log("startSDStorageFileUpload response:", resp); if ("error" in resp) { console.error("Upload error:", resp.error.message); setUploadError(resp.error.data || resp.error.message); setUploadState("idle"); console.log("Upload state set to 'idle'"); return; } const { alreadyUploadedBytes, dataChannel } = resp.result as { alreadyUploadedBytes: number; dataChannel: string; }; console.log( `Already uploaded bytes: ${alreadyUploadedBytes}, Data channel: ${dataChannel}`, ); if (isOnDevice) { handleHttpUpload(file, alreadyUploadedBytes, dataChannel); } else { handleWebRTCUpload(file, alreadyUploadedBytes, dataChannel); } }); } else { send("startStorageFileUpload", { filename: file.name, size: file.size }, resp => { console.log("startStorageFileUpload response:", resp); if ("error" in resp) { console.error("Upload error:", resp.error.message); setUploadError(resp.error.data || resp.error.message); setUploadState("idle"); console.log("Upload state set to 'idle'"); return; } const { alreadyUploadedBytes, dataChannel } = resp.result as { alreadyUploadedBytes: number; dataChannel: string; }; console.log( `Already uploaded bytes: ${alreadyUploadedBytes}, Data channel: ${dataChannel}`, ); if (isOnDevice) { handleHttpUpload(file, alreadyUploadedBytes, dataChannel); } else { handleWebRTCUpload(file, alreadyUploadedBytes, dataChannel); } }); } } }; return (
{ if (uploadState === "idle") { document.getElementById("file-upload")?.click(); } }} className="block select-none" >
{uploadState === "idle" && (

{incompleteFileName ? `Click to select "${incompleteFileName.replace(".incomplete", "")}"` : "Click to select a file"}

Do not support directory

)} {uploadState === "uploading" && (

Uploading {formatters.truncateMiddle(uploadedFileName, 30)}

{formatters.bytes(uploadedFileSize || 0)}

Uploading... {uploadSpeed !== null ? `${formatters.bytes(uploadSpeed)}/s` : "Calculating..."}
)} {uploadState === "success" && (

Upload successful

{formatters.truncateMiddle(uploadedFileName, 40)} has been uploaded

)}
{fileError && (

{fileError}

)}
{/* Display upload error if present */} {uploadError && (
Error: {uploadError}
)}
{uploadState === "uploading" ? (
); } function ErrorView({ errorMessage, onClose, onRetry, }: { errorMessage: string | null; onClose: () => void; onRetry: () => void; }) { return (

Mount Error

An error occurred while attempting to mount the media. Please try again.

{errorMessage && (

{errorMessage}

)}
); } function PreUploadedImageItem({ name, size, uploadedAt, isSelected, isIncomplete, onDownload, onDelete, onContinueUpload, }: { name: string; size: string; uploadedAt: string; isSelected: boolean; isIncomplete: boolean; onDownload: () => void; onDelete: () => void; onContinueUpload: () => void; }) { const [isHovering, setIsHovering] = useState(false); return ( ); } function ViewHeader({ title, description }: { title: string; description: string }) { return (

{title}

{description}
); }