diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go
new file mode 100644
index 0000000..a794bba
--- /dev/null
+++ b/internal/plugin/plugin.go
@@ -0,0 +1,53 @@
+package plugin
+
+import (
+ "fmt"
+ "kvm/internal/storage"
+ "os"
+ "path"
+
+ "github.com/google/uuid"
+)
+
+const pluginsFolder = "/userdata/jetkvm/plugins"
+const pluginsUploadFolder = pluginsFolder + "/_uploads"
+
+func init() {
+ _ = os.MkdirAll(pluginsUploadFolder, 0755)
+}
+
+func RpcPluginStartUpload(filename string, size int64) (*storage.StorageFileUpload, error) {
+ sanitizedFilename, err := storage.SanitizeFilename(filename)
+ if err != nil {
+ return nil, err
+ }
+
+ filePath := path.Join(pluginsUploadFolder, sanitizedFilename)
+ uploadPath := filePath + ".incomplete"
+
+ if _, err := os.Stat(filePath); err == nil {
+ return nil, fmt.Errorf("file already exists: %s", sanitizedFilename)
+ }
+
+ var alreadyUploadedBytes int64 = 0
+ if stat, err := os.Stat(uploadPath); err == nil {
+ alreadyUploadedBytes = stat.Size()
+ }
+
+ uploadId := "plugin_" + uuid.New().String()
+ file, err := os.OpenFile(uploadPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
+ if err != nil {
+ return nil, fmt.Errorf("failed to open file for upload: %v", err)
+ }
+
+ storage.AddPendingUpload(uploadId, storage.PendingUpload{
+ File: file,
+ Size: size,
+ AlreadyUploadedBytes: alreadyUploadedBytes,
+ })
+
+ return &storage.StorageFileUpload{
+ AlreadyUploadedBytes: alreadyUploadedBytes,
+ DataChannel: uploadId,
+ }, nil
+}
diff --git a/internal/storage/type.go b/internal/storage/type.go
new file mode 100644
index 0000000..ba7a123
--- /dev/null
+++ b/internal/storage/type.go
@@ -0,0 +1,6 @@
+package storage
+
+type StorageFileUpload struct {
+ AlreadyUploadedBytes int64 `json:"alreadyUploadedBytes"`
+ DataChannel string `json:"dataChannel"`
+}
diff --git a/internal/storage/uploads.go b/internal/storage/uploads.go
new file mode 100644
index 0000000..48fdaf7
--- /dev/null
+++ b/internal/storage/uploads.go
@@ -0,0 +1,34 @@
+package storage
+
+import (
+ "os"
+ "sync"
+)
+
+type PendingUpload struct {
+ File *os.File
+ Size int64
+ AlreadyUploadedBytes int64
+}
+
+var pendingUploads = make(map[string]PendingUpload)
+var pendingUploadsMutex sync.Mutex
+
+func GetPendingUpload(uploadId string) (PendingUpload, bool) {
+ pendingUploadsMutex.Lock()
+ defer pendingUploadsMutex.Unlock()
+ upload, ok := pendingUploads[uploadId]
+ return upload, ok
+}
+
+func AddPendingUpload(uploadId string, upload PendingUpload) {
+ pendingUploadsMutex.Lock()
+ defer pendingUploadsMutex.Unlock()
+ pendingUploads[uploadId] = upload
+}
+
+func DeletePendingUpload(uploadId string) {
+ pendingUploadsMutex.Lock()
+ defer pendingUploadsMutex.Unlock()
+ delete(pendingUploads, uploadId)
+}
diff --git a/internal/storage/utils.go b/internal/storage/utils.go
new file mode 100644
index 0000000..e622fc2
--- /dev/null
+++ b/internal/storage/utils.go
@@ -0,0 +1,19 @@
+package storage
+
+import (
+ "errors"
+ "path/filepath"
+ "strings"
+)
+
+func SanitizeFilename(filename string) (string, error) {
+ cleanPath := filepath.Clean(filename)
+ if filepath.IsAbs(cleanPath) || strings.Contains(cleanPath, "..") {
+ return "", errors.New("invalid filename")
+ }
+ sanitized := filepath.Base(cleanPath)
+ if sanitized == "." || sanitized == string(filepath.Separator) {
+ return "", errors.New("invalid filename")
+ }
+ return sanitized, nil
+}
diff --git a/jsonrpc.go b/jsonrpc.go
index 2ce5f18..34d6ef9 100644
--- a/jsonrpc.go
+++ b/jsonrpc.go
@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
+ "kvm/internal/plugin"
"log"
"os"
"os/exec"
@@ -554,4 +555,5 @@ var rpcHandlers = map[string]RPCHandler{
"getWakeOnLanDevices": {Func: rpcGetWakeOnLanDevices},
"setWakeOnLanDevices": {Func: rpcSetWakeOnLanDevices, Params: []string{"params"}},
"resetConfig": {Func: rpcResetConfig},
+ "pluginStartUpload": {Func: plugin.RpcPluginStartUpload, Params: []string{"filename", "size"}},
}
diff --git a/ui/src/components/MountMediaDialog.tsx b/ui/src/components/MountMediaDialog.tsx
index 505d233..8deb4a5 100644
--- a/ui/src/components/MountMediaDialog.tsx
+++ b/ui/src/components/MountMediaDialog.tsx
@@ -1516,7 +1516,7 @@ function PreUploadedImageItem({
);
}
-function ViewHeader({ title, description }: { title: string; description: string }) {
+export function ViewHeader({ title, description }: { title: string; description: string }) {
return (
diff --git a/ui/src/components/UploadPluginDialog.tsx b/ui/src/components/UploadPluginDialog.tsx
new file mode 100644
index 0000000..5fb950f
--- /dev/null
+++ b/ui/src/components/UploadPluginDialog.tsx
@@ -0,0 +1,482 @@
+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 {
+ 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 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 }) {
+ return (
+
+
+
+
+
+

+

+
+
{
+ setOpen(false)
+ }}
+ onCancelUpload={() => {
+ setOpen(false)
+ }}
+ />
+
+
+
+
+
+ );
+}
+
+// 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,
+ onCancelUpload,
+}: {
+ onBack: () => void;
+ onCancelUpload: () => 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");
+ 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");
+ } 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" ? (
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/ui/src/components/sidebar/settings.tsx b/ui/src/components/sidebar/settings.tsx
index ec606a6..bb9439a 100644
--- a/ui/src/components/sidebar/settings.tsx
+++ b/ui/src/components/sidebar/settings.tsx
@@ -4,6 +4,7 @@ import {
useSettingsStore,
useUiStore,
useUpdateStore,
+ usePluginStore,
} from "@/hooks/stores";
import { Checkbox } from "@components/Checkbox";
import { Button, LinkButton } from "@components/Button";
@@ -25,6 +26,7 @@ import LocalAuthPasswordDialog from "@/components/LocalAuthPasswordDialog";
import { LocalDevice } from "@routes/devices.$id";
import { useRevalidator } from "react-router-dom";
import { ShieldCheckIcon } from "@heroicons/react/20/solid";
+import UploadPluginModal from "@components/UploadPluginDialog";
export function SettingsItem({
title,
@@ -251,6 +253,8 @@ export default function SettingsSidebar() {
}
};
+ const {isPluginUploadModalOpen, setIsPluginUploadModalOpen} = usePluginStore();
+
useEffect(() => {
getCloudState();
@@ -743,6 +747,44 @@ export default function SettingsSidebar() {
>
) : null}
+
+
+
+
+ setIsPluginUploadModalOpen(true)}
+ />
+
+
+
+
(set => ({
setModalView: view => set({ modalView: view }),
setErrorMessage: message => set({ errorMessage: message }),
}));
+
+
+interface PluginState {
+ isPluginUploadModalOpen: boolean;
+ setIsPluginUploadModalOpen: (isOpen: boolean) => void;
+}
+
+export const usePluginStore = create(set => ({
+ isPluginUploadModalOpen: false,
+ setIsPluginUploadModalOpen: isOpen => set({ isPluginUploadModalOpen: isOpen }),
+}));
\ No newline at end of file
diff --git a/usb_mass_storage.go b/usb_mass_storage.go
index b897c20..b72ab97 100644
--- a/usb_mass_storage.go
+++ b/usb_mass_storage.go
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"io"
+ "kvm/internal/storage"
"kvm/resource"
"log"
"net/http"
@@ -252,7 +253,7 @@ func rpcMountWithWebRTC(filename string, size int64, mode VirtualMediaMode) erro
}
func rpcMountWithStorage(filename string, mode VirtualMediaMode) error {
- filename, err := sanitizeFilename(filename)
+ filename, err := storage.SanitizeFilename(filename)
if err != nil {
return err
}
@@ -341,20 +342,8 @@ func rpcListStorageFiles() (*StorageFiles, error) {
return &StorageFiles{Files: storageFiles}, nil
}
-func sanitizeFilename(filename string) (string, error) {
- cleanPath := filepath.Clean(filename)
- if filepath.IsAbs(cleanPath) || strings.Contains(cleanPath, "..") {
- return "", errors.New("invalid filename")
- }
- sanitized := filepath.Base(cleanPath)
- if sanitized == "." || sanitized == string(filepath.Separator) {
- return "", errors.New("invalid filename")
- }
- return sanitized, nil
-}
-
func rpcDeleteStorageFile(filename string) error {
- sanitizedFilename, err := sanitizeFilename(filename)
+ sanitizedFilename, err := storage.SanitizeFilename(filename)
if err != nil {
return err
}
@@ -373,15 +362,10 @@ func rpcDeleteStorageFile(filename string) error {
return nil
}
-type StorageFileUpload struct {
- AlreadyUploadedBytes int64 `json:"alreadyUploadedBytes"`
- DataChannel string `json:"dataChannel"`
-}
-
const uploadIdPrefix = "upload_"
-func rpcStartStorageFileUpload(filename string, size int64) (*StorageFileUpload, error) {
- sanitizedFilename, err := sanitizeFilename(filename)
+func rpcStartStorageFileUpload(filename string, size int64) (*storage.StorageFileUpload, error) {
+ sanitizedFilename, err := storage.SanitizeFilename(filename)
if err != nil {
return nil, err
}
@@ -403,28 +387,19 @@ func rpcStartStorageFileUpload(filename string, size int64) (*StorageFileUpload,
if err != nil {
return nil, fmt.Errorf("failed to open file for upload: %v", err)
}
- pendingUploadsMutex.Lock()
- pendingUploads[uploadId] = pendingUpload{
+
+ storage.AddPendingUpload(uploadId, storage.PendingUpload{
File: file,
Size: size,
AlreadyUploadedBytes: alreadyUploadedBytes,
- }
- pendingUploadsMutex.Unlock()
- return &StorageFileUpload{
+ })
+
+ return &storage.StorageFileUpload{
AlreadyUploadedBytes: alreadyUploadedBytes,
DataChannel: uploadId,
}, nil
}
-type pendingUpload struct {
- File *os.File
- Size int64
- AlreadyUploadedBytes int64
-}
-
-var pendingUploads = make(map[string]pendingUpload)
-var pendingUploadsMutex sync.Mutex
-
type UploadProgress struct {
Size int64
AlreadyUploadedBytes int64
@@ -433,9 +408,7 @@ type UploadProgress struct {
func handleUploadChannel(d *webrtc.DataChannel) {
defer d.Close()
uploadId := d.Label()
- pendingUploadsMutex.Lock()
- pendingUpload, ok := pendingUploads[uploadId]
- pendingUploadsMutex.Unlock()
+ pendingUpload, ok := storage.GetPendingUpload(uploadId)
if !ok {
logger.Warnf("upload channel opened for unknown upload: %s", uploadId)
return
@@ -454,9 +427,7 @@ func handleUploadChannel(d *webrtc.DataChannel) {
} else {
logger.Warnf("uploaded ended before the complete file received")
}
- pendingUploadsMutex.Lock()
- delete(pendingUploads, uploadId)
- pendingUploadsMutex.Unlock()
+ storage.DeletePendingUpload(uploadId)
}()
uploadComplete := make(chan struct{})
lastProgressTime := time.Now()
@@ -502,9 +473,7 @@ func handleUploadChannel(d *webrtc.DataChannel) {
func handleUploadHttp(c *gin.Context) {
uploadId := c.Query("uploadId")
- pendingUploadsMutex.Lock()
- pendingUpload, ok := pendingUploads[uploadId]
- pendingUploadsMutex.Unlock()
+ pendingUpload, ok := storage.GetPendingUpload(uploadId)
if !ok {
c.JSON(http.StatusNotFound, gin.H{"error": "Upload not found"})
return
@@ -524,9 +493,7 @@ func handleUploadHttp(c *gin.Context) {
} else {
logger.Warnf("uploaded ended before the complete file received")
}
- pendingUploadsMutex.Lock()
- delete(pendingUploads, uploadId)
- pendingUploadsMutex.Unlock()
+ storage.DeletePendingUpload(uploadId)
}()
reader := c.Request.Body