import Card, { GridCard } from "@/components/Card"; import { useEffect, useRef, useState } from "react"; import { Button } from "@components/Button"; import LogoBlueIcon from "@/assets/logo-blue.svg"; import LogoWhiteIcon from "@/assets/logo-white.svg"; import Modal from "@components/Modal"; import { PluginManifest, usePluginStore, useRTCStore, } from "../hooks/stores"; import { cx } from "../cva.config"; import { LuCheck, LuUpload, } from "react-icons/lu"; import { formatters } from "@/utils"; import { PlusCircleIcon } from "@heroicons/react/20/solid"; import AutoHeight from "./AutoHeight"; import { useJsonRpc } from "../hooks/useJsonRpc"; import { ExclamationTriangleIcon } from "@heroicons/react/20/solid"; import notifications from "../notifications"; import { isOnDevice } from "../main"; import { ViewHeader } from "./MountMediaDialog"; export default function UploadPluginModal({ open, setOpen, }: { open: boolean; setOpen: (open: boolean) => void; }) { return ( setOpen(false)}> ); } function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) { const { pluginUploadModalView, setPluginUploadModalView, pluginUploadFilename, setPluginUploadFilename, pluginUploadManifest, setPluginUploadManifest, setConfiguringPlugin, setPluginConfigureModalOpen, } = usePluginStore(); const [send] = useJsonRpc(); const [extractError, setExtractError] = useState(null); function extractPlugin(filename: string) { send("pluginExtract", { filename }, resp => { if ("error" in resp) { setExtractError(resp.error.data || resp.error.message); return } setPluginUploadManifest(resp.result as PluginManifest); }); } return (
JetKVM Logo JetKVM Logo {!extractError && pluginUploadModalView === "upload" && { setOpen(false) }} onUploadCompleted={(filename) => { setPluginUploadFilename(filename) setPluginUploadModalView("install") extractPlugin(filename) }} />} {extractError && ( { setOpen(false) setPluginUploadFilename(null) setExtractError(null) }} onRetry={() => { setExtractError(null) setPluginUploadFilename(null) setPluginUploadModalView("upload") }} /> )} {!extractError && pluginUploadModalView === "install" && { setOpen(false) setPluginUploadFilename(null) if (pluginUploadManifest) { setConfiguringPlugin(pluginUploadManifest.name) setPluginConfigureModalOpen(true) } setPluginUploadManifest(null) setPluginUploadModalView("upload") }} onBack={() => { setPluginUploadModalView("upload") setPluginUploadFilename(null) }} />}
); } // This is pretty much a copy-paste from the UploadFileView component in the MountMediaDialog just with the media terminology changed and the rpc method changed. // TODO: refactor to a shared component function UploadFileView({ onBack, onUploadCompleted, }: { onBack: () => void; onUploadCompleted: (filename: string) => void; }) { 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 () => { 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"); onUploadCompleted(file.name); 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 = `${import.meta.env.VITE_SIGNAL_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"); onUploadCompleted(file.name); } 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); 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'"); send("pluginStartUpload", { filename: file.name, size: file.size }, resp => { console.log("pluginStartUpload 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" && (

Click to select a file

Supported formats: TAR, TAR.GZ

)} {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 InstallPluginView({ filename, manifest, onInstall, onBack, }: { filename: string; manifest: PluginManifest | null; onInstall: () => void; onBack: () => void; }) { const [send] = useJsonRpc(); const [error, setError] = useState(null); const [installing, setInstalling] = useState(false); function handleInstall() { if (installing) return; setInstalling(true); send("pluginInstall", { name: manifest!.name, version: manifest!.version }, resp => { if ("error" in resp) { setError(resp.error.message); return } setInstalling(false); onInstall(); }); } return (
{manifest && (

{manifest.name}

{manifest.description}

Version: {manifest.version}

{manifest.homepage}

)} {error && (
Error: {error}
)}
); } function ErrorView({ errorMessage, onClose, onRetry, }: { errorMessage: string | null; onClose: () => void; onRetry: () => void; }) { return (

Plugin Extract Error

An error occurred while attempting to extract the plugin. Please ensure the plugin is valid and try again.

{errorMessage && (

{errorMessage}

)}
); }